mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add timeline post embeds for images and videos with download functionality

+781 -51
+54
.github/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + 9 + jobs: 10 + analyze-and-test: 11 + runs-on: ubuntu-latest 12 + timeout-minutes: 20 13 + 14 + steps: 15 + - name: Checkout repository 16 + uses: actions/checkout@v4 17 + 18 + - name: Setup Java 19 + uses: actions/setup-java@v4 20 + with: 21 + distribution: 'zulu' 22 + java-version: '17' 23 + cache: 'gradle' 24 + 25 + - name: Setup Flutter 26 + uses: subosito/flutter-action@v2 27 + with: 28 + flutter-version: '3.27.5' 29 + channel: 'stable' 30 + cache: true 31 + cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' 32 + cache-path: '${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:' 33 + 34 + - name: Get dependencies 35 + run: flutter pub get 36 + 37 + - name: Analyze code 38 + run: flutter analyze 39 + 40 + - name: Run code generation 41 + run: dart run build_runner build --delete-conflicting-outputs 42 + 43 + - name: Run tests with coverage 44 + run: flutter test --coverage 45 + 46 + - name: Upload coverage to Codecov 47 + uses: codecov/codecov-action@v4 48 + with: 49 + files: ./coverage/lcov.info 50 + token: ${{ secrets.CODECOV_TOKEN }} 51 + fail_ci_if_error: false 52 + flags: unittests 53 + name: codecov-lazurite 54 + verbose: true
+27
codecov.yml
··· 1 + coverage: 2 + precision: 2 3 + round: down 4 + range: "70...100" 5 + 6 + status: 7 + project: 8 + default: 9 + target: 80% # our true target is 95% 10 + threshold: 1% 11 + if_ci_failed: error 12 + 13 + patch: 14 + default: 15 + target: 80% # true target is 95% 16 + threshold: 1% 17 + 18 + comment: 19 + layout: "reach,diff,flags,files,footer" 20 + behavior: default 21 + require_changes: false 22 + 23 + ignore: 24 + - "**/*.g.dart" 25 + - "**/*.freezed.dart" 26 + - "lib/src/app/**" 27 + - "test/**"
+1 -1
doc/roadmap.txt
··· 82 82 Tasks: 83 83 - [x] Profile screen: fetch + cache profile + author feed, cursor paging. 84 84 - [x] Search screen: query -> paged results; store recent searches (local). 85 - - [ ] Shared hydration: ensure embeds/media are rendered consistently. 85 + - [x] Shared hydration: ensure embeds/media are rendered consistently. 86 86 - [ ] Add routes 87 87 - [ ] /search?q=<query> 88 88 (SearchScreen has initialQuery but router doesn't wire query params)
+20 -12
lib/src/core/utils/logger_provider.g.dart
··· 14 14 15 15 final class LoggerProvider extends $FunctionalProvider<Logger, Logger, Logger> 16 16 with $Provider<Logger> { 17 - LoggerProvider._({required LoggerFamily super.from, required String super.argument}) 18 - : super( 19 - retry: null, 20 - name: r'loggerProvider', 21 - isAutoDispose: false, 22 - dependencies: null, 23 - $allTransitiveDependencies: null, 24 - ); 17 + LoggerProvider._({ 18 + required LoggerFamily super.from, 19 + required String super.argument, 20 + }) : super( 21 + retry: null, 22 + name: r'loggerProvider', 23 + isAutoDispose: false, 24 + dependencies: null, 25 + $allTransitiveDependencies: null, 26 + ); 25 27 26 28 @override 27 29 String debugGetCreateSourceHash() => _$loggerHash(); ··· 35 37 36 38 @$internal 37 39 @override 38 - $ProviderElement<Logger> $createElement($ProviderPointer pointer) => $ProviderElement(pointer); 40 + $ProviderElement<Logger> $createElement($ProviderPointer pointer) => 41 + $ProviderElement(pointer); 39 42 40 43 @override 41 44 Logger create(Ref ref) { ··· 45 48 46 49 /// {@macro riverpod.override_with_value} 47 50 Override overrideWithValue(Logger value) { 48 - return $ProviderOverride(origin: this, providerOverride: $SyncValueProvider<Logger>(value)); 51 + return $ProviderOverride( 52 + origin: this, 53 + providerOverride: $SyncValueProvider<Logger>(value), 54 + ); 49 55 } 50 56 51 57 @override ··· 61 67 62 68 String _$loggerHash() => r'bd47f4c6cd32d9afa5bd157104a6ad483aaafc58'; 63 69 64 - final class LoggerFamily extends $Family with $FunctionalFamilyOverride<Logger, String> { 70 + final class LoggerFamily extends $Family 71 + with $FunctionalFamilyOverride<Logger, String> { 65 72 LoggerFamily._() 66 73 : super( 67 74 retry: null, ··· 71 78 isAutoDispose: false, 72 79 ); 73 80 74 - LoggerProvider call(String name) => LoggerProvider._(argument: name, from: this); 81 + LoggerProvider call(String name) => 82 + LoggerProvider._(argument: name, from: this); 75 83 76 84 @override 77 85 String toString() => r'loggerProvider';
+34 -14
lib/src/features/auth/application/auth_providers.g.dart
··· 13 13 final secureStorageProvider = SecureStorageProvider._(); 14 14 15 15 final class SecureStorageProvider 16 - extends $FunctionalProvider<FlutterSecureStorage, FlutterSecureStorage, FlutterSecureStorage> 16 + extends 17 + $FunctionalProvider< 18 + FlutterSecureStorage, 19 + FlutterSecureStorage, 20 + FlutterSecureStorage 21 + > 17 22 with $Provider<FlutterSecureStorage> { 18 23 SecureStorageProvider._() 19 24 : super( ··· 31 36 32 37 @$internal 33 38 @override 34 - $ProviderElement<FlutterSecureStorage> $createElement($ProviderPointer pointer) => 35 - $ProviderElement(pointer); 39 + $ProviderElement<FlutterSecureStorage> $createElement( 40 + $ProviderPointer pointer, 41 + ) => $ProviderElement(pointer); 36 42 37 43 @override 38 44 FlutterSecureStorage create(Ref ref) { ··· 95 101 final identityRepositoryProvider = IdentityRepositoryProvider._(); 96 102 97 103 final class IdentityRepositoryProvider 98 - extends $FunctionalProvider<IdentityRepository, IdentityRepository, IdentityRepository> 104 + extends 105 + $FunctionalProvider< 106 + IdentityRepository, 107 + IdentityRepository, 108 + IdentityRepository 109 + > 99 110 with $Provider<IdentityRepository> { 100 111 IdentityRepositoryProvider._() 101 112 : super( ··· 113 124 114 125 @$internal 115 126 @override 116 - $ProviderElement<IdentityRepository> $createElement($ProviderPointer pointer) => 117 - $ProviderElement(pointer); 127 + $ProviderElement<IdentityRepository> $createElement( 128 + $ProviderPointer pointer, 129 + ) => $ProviderElement(pointer); 118 130 119 131 @override 120 132 IdentityRepository create(Ref ref) { ··· 130 142 } 131 143 } 132 144 133 - String _$identityRepositoryHash() => r'38ca9e5b495eecbd5c25af2f3968a92b89f90c30'; 145 + String _$identityRepositoryHash() => 146 + r'38ca9e5b495eecbd5c25af2f3968a92b89f90c30'; 134 147 135 148 @ProviderFor(oauthClient) 136 149 final oauthClientProvider = OauthClientProvider._(); 137 150 138 - final class OauthClientProvider extends $FunctionalProvider<OAuthClient, OAuthClient, OAuthClient> 151 + final class OauthClientProvider 152 + extends $FunctionalProvider<OAuthClient, OAuthClient, OAuthClient> 139 153 with $Provider<OAuthClient> { 140 154 OauthClientProvider._() 141 155 : super( ··· 170 184 } 171 185 } 172 186 173 - String _$oauthClientHash() => r'5c4f5fd51c2d9f1936f004fba9dc1fa4b362e6ec'; 187 + String _$oauthClientHash() => r'4262ff26da2346cd4a0fb28eaacc66c2b0587cdf'; 174 188 175 189 @ProviderFor(serverMetadataRepository) 176 190 final serverMetadataRepositoryProvider = ServerMetadataRepositoryProvider._(); ··· 199 213 200 214 @$internal 201 215 @override 202 - $ProviderElement<ServerMetadataRepository> $createElement($ProviderPointer pointer) => 203 - $ProviderElement(pointer); 216 + $ProviderElement<ServerMetadataRepository> $createElement( 217 + $ProviderPointer pointer, 218 + ) => $ProviderElement(pointer); 204 219 205 220 @override 206 221 ServerMetadataRepository create(Ref ref) { ··· 216 231 } 217 232 } 218 233 219 - String _$serverMetadataRepositoryHash() => r'8a4213bf3952ed89db571088d94fa6998211f96e'; 234 + String _$serverMetadataRepositoryHash() => 235 + r'8a4213bf3952ed89db571088d94fa6998211f96e'; 220 236 221 237 @ProviderFor(authRepository) 222 238 final authRepositoryProvider = AuthRepositoryProvider._(); ··· 262 278 @ProviderFor(AuthNotifier) 263 279 final authProvider = AuthNotifierProvider._(); 264 280 265 - final class AuthNotifierProvider extends $NotifierProvider<AuthNotifier, AuthState> { 281 + final class AuthNotifierProvider 282 + extends $NotifierProvider<AuthNotifier, AuthState> { 266 283 AuthNotifierProvider._() 267 284 : super( 268 285 from: null, ··· 283 300 284 301 /// {@macro riverpod.override_with_value} 285 302 Override overrideWithValue(AuthState value) { 286 - return $ProviderOverride(origin: this, providerOverride: $SyncValueProvider<AuthState>(value)); 303 + return $ProviderOverride( 304 + origin: this, 305 + providerOverride: $SyncValueProvider<AuthState>(value), 306 + ); 287 307 } 288 308 } 289 309
+2 -2
lib/src/features/profile/application/profile_providers.g.dart
··· 97 97 } 98 98 } 99 99 100 - String _$profileNotifierHash() => r'64dd55fec0d7c7189141790373270bc9eb244789'; 100 + String _$profileNotifierHash() => r'8c85b7b98f88588be316afe12936a90082506568'; 101 101 102 102 final class ProfileNotifierFamily extends $Family 103 103 with ··· 187 187 } 188 188 189 189 String _$authorFeedNotifierHash() => 190 - r'905cb265b01479bfbfcd45bfe6f88573a6357a1a'; 190 + r'b241402d3ec7ba5ca64908fd166bbfa3d3b126c7'; 191 191 192 192 final class AuthorFeedNotifierFamily extends $Family 193 193 with
+1 -1
lib/src/features/search/application/search_providers.g.dart
··· 96 96 } 97 97 } 98 98 99 - String _$searchNotifierHash() => r'415fa1737884a131e01380878ece09cba2a15120'; 99 + String _$searchNotifierHash() => r'fae1ada9ad3fa7cb3b48ea549a570f132f718126'; 100 100 101 101 final class SearchNotifierFamily extends $Family 102 102 with
+1
lib/src/features/timeline/infrastructure/timeline_repository.dart
··· 19 19 cid: json['cid'], 20 20 authorDid: json['author']['did'], 21 21 record: jsonEncode(json['record']), 22 + embed: Value(json['embed'] != null ? jsonEncode(json['embed']) : null), 22 23 indexedAt: Value(DateTime.tryParse(json['indexedAt'] ?? '')), 23 24 replyCount: Value(json['replyCount'] ?? 0), 24 25 repostCount: Value(json['repostCount'] ?? 0),
+147
lib/src/features/timeline/presentation/widgets/embeds/embed_images.dart
··· 1 + import 'package:dio/dio.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:gal/gal.dart'; 4 + import 'package:path_provider/path_provider.dart'; 5 + 6 + class EmbedImages extends StatelessWidget { 7 + const EmbedImages({required this.images, super.key}); 8 + 9 + final List<dynamic> images; 10 + 11 + @override 12 + Widget build(BuildContext context) { 13 + if (images.isEmpty) return const SizedBox.shrink(); 14 + 15 + if (images.length == 1) { 16 + return _SingleImage(image: images.first as Map<String, dynamic>); 17 + } 18 + 19 + if (images.length == 2) { 20 + return Row( 21 + children: [ 22 + Expanded(child: _SingleImage(image: images[0] as Map<String, dynamic>)), 23 + const SizedBox(width: 4), 24 + Expanded(child: _SingleImage(image: images[1] as Map<String, dynamic>)), 25 + ], 26 + ); 27 + } 28 + 29 + if (images.length == 3) { 30 + return Column( 31 + children: [ 32 + _SingleImage(image: images[0] as Map<String, dynamic>), 33 + const SizedBox(height: 4), 34 + Row( 35 + children: [ 36 + Expanded(child: _SingleImage(image: images[1] as Map<String, dynamic>)), 37 + const SizedBox(width: 4), 38 + Expanded(child: _SingleImage(image: images[2] as Map<String, dynamic>)), 39 + ], 40 + ), 41 + ], 42 + ); 43 + } 44 + 45 + return Column( 46 + children: [ 47 + Row( 48 + children: [ 49 + Expanded(child: _SingleImage(image: images[0] as Map<String, dynamic>)), 50 + const SizedBox(width: 4), 51 + Expanded(child: _SingleImage(image: images[1] as Map<String, dynamic>)), 52 + ], 53 + ), 54 + const SizedBox(height: 4), 55 + Row( 56 + children: [ 57 + Expanded(child: _SingleImage(image: images[2] as Map<String, dynamic>)), 58 + const SizedBox(width: 4), 59 + Expanded(child: _SingleImage(image: images[3] as Map<String, dynamic>)), 60 + ], 61 + ), 62 + ], 63 + ); 64 + } 65 + } 66 + 67 + class _SingleImage extends StatelessWidget { 68 + const _SingleImage({required this.image}); 69 + 70 + final Map<String, dynamic> image; 71 + 72 + Future<void> _downloadImage(BuildContext context, String url) async { 73 + try { 74 + final dio = Dio(); 75 + final tempDir = await getTemporaryDirectory(); 76 + final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg'; 77 + final path = '${tempDir.path}/$fileName'; 78 + 79 + await dio.download(url, path); 80 + await Gal.putImage(path); 81 + 82 + if (context.mounted) { 83 + ScaffoldMessenger.of( 84 + context, 85 + ).showSnackBar(const SnackBar(content: Text('Image saved to gallery'))); 86 + } 87 + } catch (e) { 88 + if (context.mounted) { 89 + ScaffoldMessenger.of( 90 + context, 91 + ).showSnackBar(SnackBar(content: Text('Failed to save image: $e'))); 92 + } 93 + } 94 + } 95 + 96 + @override 97 + Widget build(BuildContext context) { 98 + final thumb = image['thumb'] as String? ?? ''; 99 + final fullsize = image['fullsize'] as String? ?? thumb; 100 + final alt = image['alt'] as String? ?? ''; 101 + 102 + return Stack( 103 + children: [ 104 + AspectRatio( 105 + aspectRatio: 16 / 9, 106 + child: Container( 107 + decoration: BoxDecoration( 108 + borderRadius: BorderRadius.circular(8), 109 + image: DecorationImage(image: NetworkImage(thumb), fit: BoxFit.cover), 110 + ), 111 + ), 112 + ), 113 + Positioned( 114 + top: 8, 115 + right: 8, 116 + child: Container( 117 + decoration: BoxDecoration( 118 + color: Colors.black.withValues(alpha: 0.5), 119 + shape: BoxShape.circle, 120 + ), 121 + child: IconButton( 122 + icon: const Icon(Icons.download, color: Colors.white, size: 20), 123 + onPressed: () => _downloadImage(context, fullsize), 124 + tooltip: 'Download', 125 + ), 126 + ), 127 + ), 128 + if (alt.isNotEmpty) 129 + Positioned( 130 + bottom: 8, 131 + left: 8, 132 + child: Container( 133 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 134 + decoration: BoxDecoration( 135 + color: Colors.black.withValues(alpha: 0.7), 136 + borderRadius: BorderRadius.circular(4), 137 + ), 138 + child: const Text( 139 + 'ALT', 140 + style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), 141 + ), 142 + ), 143 + ), 144 + ], 145 + ); 146 + } 147 + }
+114
lib/src/features/timeline/presentation/widgets/embeds/embed_video.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:gal/gal.dart'; 4 + import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 5 + import 'package:lazurite/src/infrastructure/network/providers.dart'; 6 + import 'package:path_provider/path_provider.dart'; 7 + 8 + class EmbedVideo extends ConsumerWidget { 9 + const EmbedVideo({ 10 + required this.playlist, 11 + this.thumbnail, 12 + this.alt, 13 + this.cid, 14 + this.authorDid, 15 + super.key, 16 + }); 17 + 18 + final String playlist; 19 + final String? thumbnail; 20 + final String? alt; 21 + final String? cid; 22 + final String? authorDid; 23 + 24 + Future<void> _downloadVideo(BuildContext context, WidgetRef ref) async { 25 + if (cid == null || authorDid == null) { 26 + ScaffoldMessenger.of(context).showSnackBar( 27 + const SnackBar(content: Text('Cannot download video: missing metadata (CID/DID)')), 28 + ); 29 + return; 30 + } 31 + 32 + try { 33 + String pdsUrl = 'https://bsky.social'; 34 + try { 35 + final doc = await ref.read(identityRepositoryProvider).resolveDidDocument(authorDid!); 36 + final endpoint = doc?.pdsEndpoint; 37 + if (endpoint != null) { 38 + pdsUrl = endpoint; 39 + } 40 + } catch (e) { 41 + debugPrint('Failed to resolve DID, using fallback: $e'); 42 + } 43 + 44 + final dio = ref.read(dioPublicProvider); 45 + final tempDir = await getTemporaryDirectory(); 46 + final fileName = '${DateTime.now().millisecondsSinceEpoch}.mp4'; 47 + final path = '${tempDir.path}/$fileName'; 48 + 49 + final url = '$pdsUrl/xrpc/com.atproto.sync.getBlob?did=$authorDid&cid=$cid'; 50 + 51 + await dio.download(url, path); 52 + await Gal.putVideo(path); 53 + 54 + if (context.mounted) { 55 + ScaffoldMessenger.of( 56 + context, 57 + ).showSnackBar(const SnackBar(content: Text('Video saved to gallery'))); 58 + } 59 + } catch (e) { 60 + if (context.mounted) { 61 + ScaffoldMessenger.of( 62 + context, 63 + ).showSnackBar(SnackBar(content: Text('Failed to save video: $e'))); 64 + } 65 + } 66 + } 67 + 68 + @override 69 + Widget build(BuildContext context, WidgetRef ref) { 70 + return Stack( 71 + alignment: Alignment.center, 72 + children: [ 73 + AspectRatio( 74 + aspectRatio: 16 / 9, 75 + child: Container( 76 + decoration: BoxDecoration( 77 + borderRadius: BorderRadius.circular(8), 78 + color: Colors.black, 79 + image: thumbnail != null 80 + ? DecorationImage(image: NetworkImage(thumbnail!), fit: BoxFit.cover) 81 + : null, 82 + ), 83 + child: thumbnail == null 84 + ? const Center(child: Icon(Icons.movie, color: Colors.white54, size: 48)) 85 + : null, 86 + ), 87 + ), 88 + Container( 89 + padding: const EdgeInsets.all(12), 90 + decoration: BoxDecoration( 91 + color: Colors.black.withValues(alpha: 0.5), 92 + shape: BoxShape.circle, 93 + ), 94 + child: const Icon(Icons.play_arrow, color: Colors.white, size: 32), 95 + ), 96 + Positioned( 97 + top: 8, 98 + right: 8, 99 + child: Container( 100 + decoration: BoxDecoration( 101 + color: Colors.black.withValues(alpha: 0.5), 102 + shape: BoxShape.circle, 103 + ), 104 + child: IconButton( 105 + icon: const Icon(Icons.download, color: Colors.white, size: 20), 106 + onPressed: () => _downloadVideo(context, ref), 107 + tooltip: 'Download Video', 108 + ), 109 + ), 110 + ), 111 + ], 112 + ); 113 + } 114 + }
+9
lib/src/features/timeline/presentation/widgets/post_card.dart
··· 7 7 8 8 import 'post_actions_row.dart'; 9 9 import 'post_body.dart'; 10 + import 'post_embeds.dart'; 10 11 import 'post_header.dart'; 11 12 12 13 class PostCard extends StatelessWidget { ··· 71 72 crossAxisAlignment: CrossAxisAlignment.start, 72 73 children: [ 73 74 PostBody(text: text), 75 + if (item.post.embed != null) ...[ 76 + const SizedBox(height: 8), 77 + PostEmbeds( 78 + embed: jsonDecode(item.post.embed!) as Map<String, dynamic>, 79 + authorDid: item.author.did, 80 + record: jsonDecode(item.post.record) as Map<String, dynamic>, 81 + ), 82 + ], 74 83 const SizedBox(height: 12), 75 84 PostActionsRow( 76 85 replyCount: item.post.replyCount,
+56
lib/src/features/timeline/presentation/widgets/post_embeds.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + import 'embeds/embed_images.dart'; 4 + import 'embeds/embed_video.dart'; 5 + 6 + class PostEmbeds extends StatelessWidget { 7 + const PostEmbeds({required this.embed, required this.authorDid, this.record, super.key}); 8 + 9 + final Map<String, dynamic> embed; 10 + final String authorDid; 11 + final Map<String, dynamic>? record; 12 + 13 + @override 14 + Widget build(BuildContext context) { 15 + final type = embed[r'$type'] as String?; 16 + 17 + if (type == 'app.bsky.embed.images#view') { 18 + final images = embed['images'] as List<dynamic>? ?? []; 19 + return EmbedImages(images: images); 20 + } 21 + 22 + if (type == 'app.bsky.embed.video#view') { 23 + // Extract CID from record if available 24 + String? cid; 25 + if (record != null && 26 + record![r'$type'] == 'app.bsky.feed.post' && 27 + record!['embed'] != null && 28 + record!['embed'][r'$type'] == 'app.bsky.embed.video') { 29 + final video = record!['embed']['video']; 30 + // The structure might be { ref: {$link: ...}, ... } or just blob? 31 + // Usually blob is { ref: {$link: ...}, mimeType: ..., size: ...} 32 + if (video != null && video['ref'] != null) { 33 + cid = video['ref'][r'$link'] as String?; 34 + } 35 + } 36 + 37 + return EmbedVideo( 38 + playlist: embed['playlist'] as String? ?? '', 39 + thumbnail: embed['thumbnail'] as String?, 40 + alt: embed['alt'] as String?, 41 + cid: cid, 42 + authorDid: authorDid, 43 + ); 44 + } 45 + 46 + if (type == 'app.bsky.embed.recordWithMedia#view') { 47 + final media = embed['media'] as Map<String, dynamic>?; 48 + if (media != null) { 49 + return PostEmbeds(embed: media, authorDid: authorDid, record: record); 50 + } 51 + } 52 + 53 + //TODO: other embeds (External, Record) 54 + return const SizedBox.shrink(); 55 + } 56 + }
+66
lib/src/infrastructure/db/app_database.g.dart
··· 46 46 type: DriftSqlType.string, 47 47 requiredDuringInsert: true, 48 48 ); 49 + static const VerificationMeta _embedMeta = const VerificationMeta('embed'); 50 + @override 51 + late final GeneratedColumn<String> embed = GeneratedColumn<String>( 52 + 'embed', 53 + aliasedName, 54 + true, 55 + type: DriftSqlType.string, 56 + requiredDuringInsert: false, 57 + ); 49 58 static const VerificationMeta _indexedAtMeta = const VerificationMeta( 50 59 'indexedAt', 51 60 ); ··· 99 108 cid, 100 109 authorDid, 101 110 record, 111 + embed, 102 112 indexedAt, 103 113 replyCount, 104 114 repostCount, ··· 148 158 } else if (isInserting) { 149 159 context.missing(_recordMeta); 150 160 } 161 + if (data.containsKey('embed')) { 162 + context.handle( 163 + _embedMeta, 164 + embed.isAcceptableOrUnknown(data['embed']!, _embedMeta), 165 + ); 166 + } 151 167 if (data.containsKey('indexed_at')) { 152 168 context.handle( 153 169 _indexedAtMeta, ··· 200 216 DriftSqlType.string, 201 217 data['${effectivePrefix}record'], 202 218 )!, 219 + embed: attachedDatabase.typeMapping.read( 220 + DriftSqlType.string, 221 + data['${effectivePrefix}embed'], 222 + ), 203 223 indexedAt: attachedDatabase.typeMapping.read( 204 224 DriftSqlType.dateTime, 205 225 data['${effectivePrefix}indexed_at'], ··· 230 250 final String cid; 231 251 final String authorDid; 232 252 final String record; 253 + final String? embed; 233 254 final DateTime? indexedAt; 234 255 final int replyCount; 235 256 final int repostCount; ··· 239 260 required this.cid, 240 261 required this.authorDid, 241 262 required this.record, 263 + this.embed, 242 264 this.indexedAt, 243 265 required this.replyCount, 244 266 required this.repostCount, ··· 251 273 map['cid'] = Variable<String>(cid); 252 274 map['author_did'] = Variable<String>(authorDid); 253 275 map['record'] = Variable<String>(record); 276 + if (!nullToAbsent || embed != null) { 277 + map['embed'] = Variable<String>(embed); 278 + } 254 279 if (!nullToAbsent || indexedAt != null) { 255 280 map['indexed_at'] = Variable<DateTime>(indexedAt); 256 281 } ··· 266 291 cid: Value(cid), 267 292 authorDid: Value(authorDid), 268 293 record: Value(record), 294 + embed: embed == null && nullToAbsent 295 + ? const Value.absent() 296 + : Value(embed), 269 297 indexedAt: indexedAt == null && nullToAbsent 270 298 ? const Value.absent() 271 299 : Value(indexedAt), ··· 285 313 cid: serializer.fromJson<String>(json['cid']), 286 314 authorDid: serializer.fromJson<String>(json['authorDid']), 287 315 record: serializer.fromJson<String>(json['record']), 316 + embed: serializer.fromJson<String?>(json['embed']), 288 317 indexedAt: serializer.fromJson<DateTime?>(json['indexedAt']), 289 318 replyCount: serializer.fromJson<int>(json['replyCount']), 290 319 repostCount: serializer.fromJson<int>(json['repostCount']), ··· 299 328 'cid': serializer.toJson<String>(cid), 300 329 'authorDid': serializer.toJson<String>(authorDid), 301 330 'record': serializer.toJson<String>(record), 331 + 'embed': serializer.toJson<String?>(embed), 302 332 'indexedAt': serializer.toJson<DateTime?>(indexedAt), 303 333 'replyCount': serializer.toJson<int>(replyCount), 304 334 'repostCount': serializer.toJson<int>(repostCount), ··· 311 341 String? cid, 312 342 String? authorDid, 313 343 String? record, 344 + Value<String?> embed = const Value.absent(), 314 345 Value<DateTime?> indexedAt = const Value.absent(), 315 346 int? replyCount, 316 347 int? repostCount, ··· 320 351 cid: cid ?? this.cid, 321 352 authorDid: authorDid ?? this.authorDid, 322 353 record: record ?? this.record, 354 + embed: embed.present ? embed.value : this.embed, 323 355 indexedAt: indexedAt.present ? indexedAt.value : this.indexedAt, 324 356 replyCount: replyCount ?? this.replyCount, 325 357 repostCount: repostCount ?? this.repostCount, ··· 331 363 cid: data.cid.present ? data.cid.value : this.cid, 332 364 authorDid: data.authorDid.present ? data.authorDid.value : this.authorDid, 333 365 record: data.record.present ? data.record.value : this.record, 366 + embed: data.embed.present ? data.embed.value : this.embed, 334 367 indexedAt: data.indexedAt.present ? data.indexedAt.value : this.indexedAt, 335 368 replyCount: data.replyCount.present 336 369 ? data.replyCount.value ··· 349 382 ..write('cid: $cid, ') 350 383 ..write('authorDid: $authorDid, ') 351 384 ..write('record: $record, ') 385 + ..write('embed: $embed, ') 352 386 ..write('indexedAt: $indexedAt, ') 353 387 ..write('replyCount: $replyCount, ') 354 388 ..write('repostCount: $repostCount, ') ··· 363 397 cid, 364 398 authorDid, 365 399 record, 400 + embed, 366 401 indexedAt, 367 402 replyCount, 368 403 repostCount, ··· 376 411 other.cid == this.cid && 377 412 other.authorDid == this.authorDid && 378 413 other.record == this.record && 414 + other.embed == this.embed && 379 415 other.indexedAt == this.indexedAt && 380 416 other.replyCount == this.replyCount && 381 417 other.repostCount == this.repostCount && ··· 387 423 final Value<String> cid; 388 424 final Value<String> authorDid; 389 425 final Value<String> record; 426 + final Value<String?> embed; 390 427 final Value<DateTime?> indexedAt; 391 428 final Value<int> replyCount; 392 429 final Value<int> repostCount; ··· 397 434 this.cid = const Value.absent(), 398 435 this.authorDid = const Value.absent(), 399 436 this.record = const Value.absent(), 437 + this.embed = const Value.absent(), 400 438 this.indexedAt = const Value.absent(), 401 439 this.replyCount = const Value.absent(), 402 440 this.repostCount = const Value.absent(), ··· 408 446 required String cid, 409 447 required String authorDid, 410 448 required String record, 449 + this.embed = const Value.absent(), 411 450 this.indexedAt = const Value.absent(), 412 451 this.replyCount = const Value.absent(), 413 452 this.repostCount = const Value.absent(), ··· 422 461 Expression<String>? cid, 423 462 Expression<String>? authorDid, 424 463 Expression<String>? record, 464 + Expression<String>? embed, 425 465 Expression<DateTime>? indexedAt, 426 466 Expression<int>? replyCount, 427 467 Expression<int>? repostCount, ··· 433 473 if (cid != null) 'cid': cid, 434 474 if (authorDid != null) 'author_did': authorDid, 435 475 if (record != null) 'record': record, 476 + if (embed != null) 'embed': embed, 436 477 if (indexedAt != null) 'indexed_at': indexedAt, 437 478 if (replyCount != null) 'reply_count': replyCount, 438 479 if (repostCount != null) 'repost_count': repostCount, ··· 446 487 Value<String>? cid, 447 488 Value<String>? authorDid, 448 489 Value<String>? record, 490 + Value<String?>? embed, 449 491 Value<DateTime?>? indexedAt, 450 492 Value<int>? replyCount, 451 493 Value<int>? repostCount, ··· 457 499 cid: cid ?? this.cid, 458 500 authorDid: authorDid ?? this.authorDid, 459 501 record: record ?? this.record, 502 + embed: embed ?? this.embed, 460 503 indexedAt: indexedAt ?? this.indexedAt, 461 504 replyCount: replyCount ?? this.replyCount, 462 505 repostCount: repostCount ?? this.repostCount, ··· 480 523 if (record.present) { 481 524 map['record'] = Variable<String>(record.value); 482 525 } 526 + if (embed.present) { 527 + map['embed'] = Variable<String>(embed.value); 528 + } 483 529 if (indexedAt.present) { 484 530 map['indexed_at'] = Variable<DateTime>(indexedAt.value); 485 531 } ··· 505 551 ..write('cid: $cid, ') 506 552 ..write('authorDid: $authorDid, ') 507 553 ..write('record: $record, ') 554 + ..write('embed: $embed, ') 508 555 ..write('indexedAt: $indexedAt, ') 509 556 ..write('replyCount: $replyCount, ') 510 557 ..write('repostCount: $repostCount, ') ··· 2122 2169 required String cid, 2123 2170 required String authorDid, 2124 2171 required String record, 2172 + Value<String?> embed, 2125 2173 Value<DateTime?> indexedAt, 2126 2174 Value<int> replyCount, 2127 2175 Value<int> repostCount, ··· 2134 2182 Value<String> cid, 2135 2183 Value<String> authorDid, 2136 2184 Value<String> record, 2185 + Value<String?> embed, 2137 2186 Value<DateTime?> indexedAt, 2138 2187 Value<int> replyCount, 2139 2188 Value<int> repostCount, ··· 2189 2238 2190 2239 ColumnFilters<String> get record => $composableBuilder( 2191 2240 column: $table.record, 2241 + builder: (column) => ColumnFilters(column), 2242 + ); 2243 + 2244 + ColumnFilters<String> get embed => $composableBuilder( 2245 + column: $table.embed, 2192 2246 builder: (column) => ColumnFilters(column), 2193 2247 ); 2194 2248 ··· 2267 2321 builder: (column) => ColumnOrderings(column), 2268 2322 ); 2269 2323 2324 + ColumnOrderings<String> get embed => $composableBuilder( 2325 + column: $table.embed, 2326 + builder: (column) => ColumnOrderings(column), 2327 + ); 2328 + 2270 2329 ColumnOrderings<DateTime> get indexedAt => $composableBuilder( 2271 2330 column: $table.indexedAt, 2272 2331 builder: (column) => ColumnOrderings(column), ··· 2308 2367 2309 2368 GeneratedColumn<String> get record => 2310 2369 $composableBuilder(column: $table.record, builder: (column) => column); 2370 + 2371 + GeneratedColumn<String> get embed => 2372 + $composableBuilder(column: $table.embed, builder: (column) => column); 2311 2373 2312 2374 GeneratedColumn<DateTime> get indexedAt => 2313 2375 $composableBuilder(column: $table.indexedAt, builder: (column) => column); ··· 2383 2445 Value<String> cid = const Value.absent(), 2384 2446 Value<String> authorDid = const Value.absent(), 2385 2447 Value<String> record = const Value.absent(), 2448 + Value<String?> embed = const Value.absent(), 2386 2449 Value<DateTime?> indexedAt = const Value.absent(), 2387 2450 Value<int> replyCount = const Value.absent(), 2388 2451 Value<int> repostCount = const Value.absent(), ··· 2393 2456 cid: cid, 2394 2457 authorDid: authorDid, 2395 2458 record: record, 2459 + embed: embed, 2396 2460 indexedAt: indexedAt, 2397 2461 replyCount: replyCount, 2398 2462 repostCount: repostCount, ··· 2405 2469 required String cid, 2406 2470 required String authorDid, 2407 2471 required String record, 2472 + Value<String?> embed = const Value.absent(), 2408 2473 Value<DateTime?> indexedAt = const Value.absent(), 2409 2474 Value<int> replyCount = const Value.absent(), 2410 2475 Value<int> repostCount = const Value.absent(), ··· 2415 2480 cid: cid, 2416 2481 authorDid: authorDid, 2417 2482 record: record, 2483 + embed: embed, 2418 2484 indexedAt: indexedAt, 2419 2485 replyCount: replyCount, 2420 2486 repostCount: repostCount,
+3
lib/src/infrastructure/db/daos/timeline_dao.dart
··· 41 41 cid: p.cid, 42 42 authorDid: p.authorDid, 43 43 record: p.record, 44 + embed: Value(p.embed), 44 45 indexedAt: Value(p.indexedAt), 45 46 replyCount: Value(p.replyCount), 46 47 repostCount: Value(p.repostCount), ··· 124 125 required this.cid, 125 126 required this.authorDid, 126 127 required this.record, 128 + this.embed, 127 129 this.indexedAt, 128 130 this.replyCount = 0, 129 131 this.repostCount = 0, ··· 134 136 final String cid; 135 137 final String authorDid; 136 138 final String record; 139 + final String? embed; 137 140 final DateTime? indexedAt; 138 141 final int replyCount; 139 142 final int repostCount;
+1
lib/src/infrastructure/db/tables.dart
··· 5 5 TextColumn get cid => text()(); 6 6 TextColumn get authorDid => text()(); 7 7 TextColumn get record => text()(); 8 + TextColumn get embed => text().nullable()(); 8 9 DateTimeColumn get indexedAt => dateTime().nullable()(); 9 10 IntColumn get replyCount => integer().withDefault(const Constant(0))(); 10 11 IntColumn get repostCount => integer().withDefault(const Constant(0))();
+18 -7
lib/src/infrastructure/network/providers.g.dart
··· 19 19 /// 20 20 /// This client is configured for the public AppView at public.api.bsky.app. 21 21 22 - final class DioPublicProvider extends $FunctionalProvider<Dio, Dio, Dio> with $Provider<Dio> { 22 + final class DioPublicProvider extends $FunctionalProvider<Dio, Dio, Dio> 23 + with $Provider<Dio> { 23 24 /// Provides the public Dio client for unauthenticated API access. 24 25 /// 25 26 /// This client is configured for the public AppView at public.api.bsky.app. ··· 39 40 40 41 @$internal 41 42 @override 42 - $ProviderElement<Dio> $createElement($ProviderPointer pointer) => $ProviderElement(pointer); 43 + $ProviderElement<Dio> $createElement($ProviderPointer pointer) => 44 + $ProviderElement(pointer); 43 45 44 46 @override 45 47 Dio create(Ref ref) { ··· 48 50 49 51 /// {@macro riverpod.override_with_value} 50 52 Override overrideWithValue(Dio value) { 51 - return $ProviderOverride(origin: this, providerOverride: $SyncValueProvider<Dio>(value)); 53 + return $ProviderOverride( 54 + origin: this, 55 + providerOverride: $SyncValueProvider<Dio>(value), 56 + ); 52 57 } 53 58 } 54 59 ··· 67 72 /// This requires a logged-in user with a resolved PDS URL. 68 73 /// Returns null if no user is logged in. 69 74 70 - final class DioPdsProvider extends $FunctionalProvider<Dio?, Dio?, Dio?> with $Provider<Dio?> { 75 + final class DioPdsProvider extends $FunctionalProvider<Dio?, Dio?, Dio?> 76 + with $Provider<Dio?> { 71 77 /// Provides the PDS Dio client for authenticated API access. 72 78 /// 73 79 /// This requires a logged-in user with a resolved PDS URL. ··· 88 94 89 95 @$internal 90 96 @override 91 - $ProviderElement<Dio?> $createElement($ProviderPointer pointer) => $ProviderElement(pointer); 97 + $ProviderElement<Dio?> $createElement($ProviderPointer pointer) => 98 + $ProviderElement(pointer); 92 99 93 100 @override 94 101 Dio? create(Ref ref) { ··· 97 104 98 105 /// {@macro riverpod.override_with_value} 99 106 Override overrideWithValue(Dio? value) { 100 - return $ProviderOverride(origin: this, providerOverride: $SyncValueProvider<Dio?>(value)); 107 + return $ProviderOverride( 108 + origin: this, 109 + providerOverride: $SyncValueProvider<Dio?>(value), 110 + ); 101 111 } 102 112 } 103 113 ··· 116 126 /// This client automatically routes requests to the correct host 117 127 /// based on endpoint metadata in the registry. 118 128 119 - final class XrpcClientProvider extends $FunctionalProvider<XrpcClient, XrpcClient, XrpcClient> 129 + final class XrpcClientProvider 130 + extends $FunctionalProvider<XrpcClient, XrpcClient, XrpcClient> 120 131 with $Provider<XrpcClient> { 121 132 /// Provides the XRPC client for making API requests. 122 133 ///
+16
pubspec.lock
··· 432 432 url: "https://pub.dev" 433 433 source: hosted 434 434 version: "4.0.0" 435 + gal: 436 + dependency: "direct main" 437 + description: 438 + name: gal 439 + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" 440 + url: "https://pub.dev" 441 + source: hosted 442 + version: "2.3.2" 435 443 glob: 436 444 dependency: transitive 437 445 description: ··· 632 640 url: "https://pub.dev" 633 641 source: hosted 634 642 version: "1.0.4" 643 + mocktail_image_network: 644 + dependency: "direct dev" 645 + description: 646 + name: mocktail_image_network 647 + sha256: a1fccbba780343517cfc552e0af2b3834d8bdb8f9f55a746c4d495ed1a8d50d6 648 + url: "https://pub.dev" 649 + source: hosted 650 + version: "1.2.0" 635 651 node_preamble: 636 652 dependency: transitive 637 653 description:
+9 -14
pubspec.yaml
··· 1 1 name: lazurite 2 - description: "A new Flutter project." 3 - # The following line prevents the package from being accidentally published to 4 - # pub.dev using `flutter pub publish`. This is preferred for private packages. 5 - publish_to: "none" # Remove this line if you wish to publish to pub.dev 2 + description: A material BlueSky client. 3 + publish_to: "none" 6 4 7 - # The following defines the version and build number for your application. 8 - # A version number is three numbers separated by dots, like 1.2.43 9 - # followed by an optional build number separated by a +. 10 - # Both the version and the builder number may be overridden in flutter 11 - # build by specifying --build-name and --build-number, respectively. 5 + # Version number can be overriden by specifying --build-name and --build-number, respectively. 6 + # 12 7 # In Android, build-name is used as versionName while build-number used as versionCode. 13 8 # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 9 + # 14 10 # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. 15 11 # Read more about iOS versioning at 16 12 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 13 + # 17 14 # In Windows, build-name is used as the major, minor, and patch parts 18 15 # of the product and file versions while build-number is used as the build suffix. 19 16 version: 1.0.0+1 ··· 58 55 sqlite3_flutter_libs: ^0.5.0 59 56 path_provider: ^2.1.2 60 57 path: ^1.9.0 58 + gal: ^2.3.0 61 59 62 60 dev_dependencies: 63 61 flutter_test: ··· 76 74 # Mocking 77 75 http_mock_adapter: ^0.6.1 78 76 mocktail: ^1.0.4 77 + mocktail_image_network: ^1.2.0 79 78 80 79 flutter: 81 80 uses-material-design: true ··· 91 90 # For details regarding adding assets from package dependencies, see 92 91 # https://flutter.dev/to/asset-from-package 93 92 94 - # To add custom fonts to your application, add a fonts section here, 95 - # in this "flutter" section. Each entry in this list should have a 96 - # "family" key with the font family name, and a "fonts" key with a 97 - # list giving the asset and other descriptors for the font. For 98 - # example: 93 + # Custom fonts. Example: 99 94 # fonts: 100 95 # - family: Schyler 101 96 # fonts:
+15
test/helpers/test_http_overrides.dart
··· 1 + import 'dart:io'; 2 + 3 + class TestHttpOverrides extends HttpOverrides { 4 + @override 5 + HttpClient createHttpClient(SecurityContext? context) { 6 + return _TestHttpClient(); 7 + } 8 + } 9 + 10 + class _TestHttpClient implements HttpClient { 11 + @override 12 + dynamic noSuchMethod(Invocation invocation) { 13 + throw UnimplementedError(); 14 + } 15 + }
+67
test/src/features/timeline/presentation/widgets/embeds/embed_images_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/src/features/timeline/presentation/widgets/embeds/embed_images.dart'; 4 + import 'package:mocktail/mocktail.dart'; 5 + import 'package:mocktail_image_network/mocktail_image_network.dart'; 6 + 7 + void main() { 8 + setUpAll(() { 9 + registerFallbackValue(const MaterialApp()); 10 + }); 11 + 12 + group('EmbedImages', () { 13 + testWidgets('renders single image', (tester) async { 14 + await mockNetworkImages(() async { 15 + await tester.pumpWidget( 16 + const MaterialApp( 17 + home: Scaffold( 18 + body: EmbedImages( 19 + images: [ 20 + {'thumb': 'https://example.com/1.jpg', 'fullsize': 'https://example.com/1.jpg'}, 21 + ], 22 + ), 23 + ), 24 + ), 25 + ); 26 + }); 27 + 28 + expect(find.byType(AspectRatio), findsOneWidget); 29 + expect(find.byIcon(Icons.download), findsOneWidget); 30 + }); 31 + 32 + testWidgets('renders two images', (tester) async { 33 + await mockNetworkImages(() async { 34 + await tester.pumpWidget( 35 + const MaterialApp( 36 + home: Scaffold( 37 + body: EmbedImages( 38 + images: [ 39 + {'thumb': '1.jpg'}, 40 + {'thumb': '2.jpg'}, 41 + ], 42 + ), 43 + ), 44 + ), 45 + ); 46 + }); 47 + expect(find.byIcon(Icons.download), findsNWidgets(2)); 48 + }); 49 + 50 + testWidgets('renders download button', (tester) async { 51 + await mockNetworkImages(() async { 52 + await tester.pumpWidget( 53 + const MaterialApp( 54 + home: Scaffold( 55 + body: EmbedImages( 56 + images: [ 57 + {'thumb': '1.jpg'}, 58 + ], 59 + ), 60 + ), 61 + ), 62 + ); 63 + }); 64 + expect(find.byType(IconButton), findsOneWidget); 65 + }); 66 + }); 67 + }
+42
test/src/features/timeline/presentation/widgets/embeds/embed_video_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/src/features/timeline/presentation/widgets/embeds/embed_video.dart'; 5 + import 'package:mocktail_image_network/mocktail_image_network.dart'; 6 + 7 + void main() { 8 + group('EmbedVideo', () { 9 + testWidgets('renders placeholder and play button', (tester) async { 10 + await mockNetworkImages(() async { 11 + await tester.pumpWidget( 12 + const ProviderScope( 13 + child: MaterialApp( 14 + home: Scaffold(body: EmbedVideo(playlist: 'video.m3u8')), 15 + ), 16 + ), 17 + ); 18 + }); 19 + 20 + expect(find.byIcon(Icons.play_arrow), findsOneWidget); 21 + expect(find.byIcon(Icons.download), findsOneWidget); 22 + }); 23 + 24 + testWidgets('shows thumbnail if provided', (tester) async { 25 + await mockNetworkImages(() async { 26 + await tester.pumpWidget( 27 + const ProviderScope( 28 + child: MaterialApp( 29 + home: Scaffold( 30 + body: EmbedVideo( 31 + playlist: 'video.m3u8', 32 + thumbnail: 'https://example.com/thumb.jpg', 33 + ), 34 + ), 35 + ), 36 + ), 37 + ); 38 + }); 39 + expect(find.byType(Container), findsWidgets); 40 + }); 41 + }); 42 + }
+78
test/src/features/timeline/presentation/widgets/post_embeds_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/src/features/timeline/presentation/widgets/embeds/embed_images.dart'; 5 + import 'package:lazurite/src/features/timeline/presentation/widgets/embeds/embed_video.dart'; 6 + import 'package:lazurite/src/features/timeline/presentation/widgets/post_embeds.dart'; 7 + 8 + void main() { 9 + group('PostEmbeds', () { 10 + testWidgets('renders specific embed types correctly', (tester) async { 11 + await tester.pumpWidget( 12 + const ProviderScope( 13 + child: MaterialApp( 14 + home: Scaffold( 15 + body: PostEmbeds( 16 + embed: {r'$type': 'app.bsky.embed.images#view', 'images': []}, 17 + authorDid: 'did:example:123', 18 + ), 19 + ), 20 + ), 21 + ), 22 + ); 23 + expect(find.byType(EmbedImages), findsOneWidget); 24 + 25 + await tester.pumpWidget( 26 + const ProviderScope( 27 + child: MaterialApp( 28 + home: Scaffold( 29 + body: PostEmbeds( 30 + authorDid: 'did:example:123', 31 + embed: { 32 + r'$type': 'app.bsky.embed.video#view', 33 + 'playlist': 'http://example.com/playlist.m3u8', 34 + }, 35 + ), 36 + ), 37 + ), 38 + ), 39 + ); 40 + expect(find.byType(EmbedVideo), findsOneWidget); 41 + }); 42 + 43 + testWidgets('handles nested recordWithMedia', (tester) async { 44 + await tester.pumpWidget( 45 + const ProviderScope( 46 + child: MaterialApp( 47 + home: Scaffold( 48 + body: PostEmbeds( 49 + authorDid: 'did:example:123', 50 + embed: { 51 + r'$type': 'app.bsky.embed.recordWithMedia#view', 52 + 'media': {r'$type': 'app.bsky.embed.images#view', 'images': []}, 53 + }, 54 + ), 55 + ), 56 + ), 57 + ), 58 + ); 59 + expect(find.byType(EmbedImages), findsOneWidget); 60 + }); 61 + 62 + testWidgets('returns empty for unknown types', (tester) async { 63 + await tester.pumpWidget( 64 + const ProviderScope( 65 + child: MaterialApp( 66 + home: Scaffold( 67 + body: PostEmbeds( 68 + embed: {r'$type': 'app.bsky.embed.unknown#view'}, 69 + authorDid: 'did:example:123', 70 + ), 71 + ), 72 + ), 73 + ), 74 + ); 75 + expect(find.byType(SizedBox), findsOneWidget); 76 + }); 77 + }); 78 + }