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

Configure Feed

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

fix: feed syncing and dev tool record parsing

+227 -88
+6
ios/Podfile.lock
··· 28 28 - Mantle (2.2.0): 29 29 - Mantle/extobjc (= 2.2.0) 30 30 - Mantle/extobjc (2.2.0) 31 + - package_info_plus (0.4.5): 32 + - Flutter 31 33 - path_provider_foundation (0.0.1): 32 34 - Flutter 33 35 - FlutterMacOS ··· 77 79 - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) 78 80 - gal (from `.symlinks/plugins/gal/darwin`) 79 81 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 82 + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) 80 83 - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 81 84 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) 82 85 - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) ··· 102 105 :path: ".symlinks/plugins/gal/darwin" 103 106 image_picker_ios: 104 107 :path: ".symlinks/plugins/image_picker_ios/ios" 108 + package_info_plus: 109 + :path: ".symlinks/plugins/package_info_plus/ios" 105 110 path_provider_foundation: 106 111 :path: ".symlinks/plugins/path_provider_foundation/darwin" 107 112 shared_preferences_foundation: ··· 121 126 image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b 122 127 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 123 128 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d 129 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 124 130 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba 125 131 SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 126 132 SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
+26 -19
lib/src/features/debug/presentation/debug_drawer.dart
··· 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 4 5 + import '../../../app/router.dart'; 5 6 import '../application/debug_overlay_controller.dart'; 6 7 import 'atproto_session_tab.dart'; 7 8 import 'network_inspector_tab.dart'; ··· 20 21 final overlayState = ref.watch(debugOverlayControllerProvider); 21 22 final theme = Theme.of(context); 22 23 23 - return Material( 24 - elevation: 16, 25 - color: theme.colorScheme.surface, 26 - child: SafeArea( 27 - left: false, 28 - child: DefaultTabController( 29 - length: 3, 30 - initialIndex: overlayState.activeTabIndex, 31 - child: Column( 32 - children: [ 33 - _buildHeader(context, ref, theme), 34 - _buildTabBar(theme), 35 - const Expanded( 36 - child: TabBarView( 37 - children: [SystemInfoTab(), AtprotoSessionTab(), NetworkInspectorTab()], 24 + return Overlay( 25 + initialEntries: [ 26 + OverlayEntry( 27 + builder: (_) => Material( 28 + elevation: 16, 29 + color: theme.colorScheme.surface, 30 + child: SafeArea( 31 + left: false, 32 + child: DefaultTabController( 33 + length: 3, 34 + initialIndex: overlayState.activeTabIndex, 35 + child: Column( 36 + children: [ 37 + _buildHeader(context, ref, theme), 38 + _buildTabBar(theme), 39 + const Expanded( 40 + child: TabBarView( 41 + children: [SystemInfoTab(), AtprotoSessionTab(), NetworkInspectorTab()], 42 + ), 43 + ), 44 + _buildFooter(context, ref, theme), 45 + ], 38 46 ), 39 47 ), 40 - _buildFooter(context, ref, theme), 41 - ], 48 + ), 42 49 ), 43 50 ), 44 - ), 51 + ], 45 52 ); 46 53 } 47 54 ··· 97 104 child: FilledButton.tonal( 98 105 onPressed: () { 99 106 ref.read(debugOverlayControllerProvider.notifier).hide(); 100 - context.push('/devtools'); 107 + rootNavigatorKey.currentContext?.go('/devtools'); 101 108 }, 102 109 child: const Row( 103 110 mainAxisAlignment: MainAxisAlignment.center,
+11 -17
lib/src/features/developer_tools/domain/repo_collection.dart
··· 2 2 /// 3 3 /// A collection is a type of record in a user's repository (e.g., 4 4 /// app.bsky.feed.post, app.bsky.actor.profile). 5 + /// 6 + /// Note: The com.atproto.repo.describeRepo API only returns collection NSIDs, 7 + /// not record counts. 5 8 class RepoCollection { 6 - const RepoCollection({required this.nsid, required this.count}); 9 + const RepoCollection({required this.nsid}); 7 10 8 - /// Creates a collection from JSON response. 9 - factory RepoCollection.fromJson(Map<String, dynamic> json) { 10 - return RepoCollection( 11 - nsid: json['nsid'] as String? ?? json['collection'] as String, 12 - count: json['count'] as int? ?? 0, 13 - ); 11 + /// Creates a collection from an NSID string. 12 + factory RepoCollection.fromNsid(String nsid) { 13 + return RepoCollection(nsid: nsid); 14 14 } 15 15 16 16 /// Collection NSID (e.g., "app.bsky.feed.post"). 17 17 final String nsid; 18 18 19 - /// Number of records in this collection. 20 - final int count; 21 - 22 19 Map<String, dynamic> toJson() { 23 - return {'nsid': nsid, 'count': count}; 20 + return {'nsid': nsid}; 24 21 } 25 22 26 23 @override 27 24 bool operator ==(Object other) => 28 25 identical(this, other) || 29 - other is RepoCollection && 30 - runtimeType == other.runtimeType && 31 - nsid == other.nsid && 32 - count == other.count; 26 + other is RepoCollection && runtimeType == other.runtimeType && nsid == other.nsid; 33 27 34 28 @override 35 - int get hashCode => Object.hash(nsid, count); 29 + int get hashCode => nsid.hashCode; 36 30 37 31 @override 38 - String toString() => 'RepoCollection(nsid: $nsid, count: $count)'; 32 + String toString() => 'RepoCollection(nsid: $nsid)'; 39 33 }
+3 -4
lib/src/features/developer_tools/infrastructure/devtools_repository.dart
··· 18 18 /// Calls com.atproto.repo.describeRepo to get available collections 19 19 /// for the specified DID. 20 20 /// 21 - /// Returns a list of collections with their NSIDs and record counts. 21 + /// Returns a list of collections with their NSIDs. Note that the API 22 + /// only returns collection NSIDs (as strings), not record counts. 22 23 Future<List<RepoCollection>> describeRepo(String did) async { 23 24 _logger.info('Describing repo for DID: $did'); 24 25 ··· 31 32 return []; 32 33 } 33 34 34 - return collections 35 - .map((json) => RepoCollection.fromJson(json as Map<String, dynamic>)) 36 - .toList(); 35 + return collections.map((nsid) => RepoCollection.fromNsid(nsid as String)).toList(); 37 36 } catch (e, stack) { 38 37 _logger.error('Failed to describe repo for DID: $did', e, stack); 39 38 rethrow;
-4
lib/src/features/developer_tools/presentation/screens/collections_page.dart
··· 149 149 150 150 return ListTile( 151 151 title: Text(collection.nsid), 152 - subtitle: Text( 153 - '${collection.count} ${collection.count == 1 ? 'record' : 'records'}', 154 - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 155 - ), 156 152 trailing: Row( 157 153 mainAxisSize: MainAxisSize.min, 158 154 children: [
+13 -3
lib/src/features/developer_tools/presentation/screens/dev_tools_home_page.dart
··· 49 49 ), 50 50 const SizedBox(height: 16), 51 51 _buildInfoCard(context, title: 'PDS Host', value: pdsUrl), 52 - const SizedBox(height: 16), 52 + const SizedBox(height: 24), 53 53 Text('Quick Actions', style: theme.textTheme.titleMedium), 54 54 const SizedBox(height: 8), 55 - const Card( 56 - child: Padding(padding: EdgeInsets.all(16.0), child: Text('Coming soon...')), 55 + Card( 56 + clipBehavior: Clip.antiAlias, 57 + child: ListTile( 58 + leading: const Icon(Icons.folder_outlined), 59 + title: const Text('Browse My Repository'), 60 + subtitle: const Text('Explore collections and records'), 61 + trailing: const Icon(Icons.chevron_right), 62 + onTap: authState is AuthStateAuthenticated 63 + ? () => context.push('/devtools/collections') 64 + : null, 65 + enabled: authState is AuthStateAuthenticated, 66 + ), 57 67 ), 58 68 ], 59 69 ),
+12 -8
lib/src/features/feeds/application/feed_providers.dart
··· 67 67 final authState = ref.watch(authProvider); 68 68 final ownerDid = (authState is AuthStateAuthenticated) ? authState.session.did : null; 69 69 70 - if (ownerDid == null) return const Stream.empty(); 70 + if (ownerDid == null) { 71 + return const Stream.empty(); 72 + } 71 73 72 74 final repository = ref.watch(feedRepositoryProvider); 73 - return repository 74 - .watchAllFeeds(ownerDid) 75 - .map((list) => list.map(SavedFeedData.fromEntity).toList()); 75 + return repository.watchAllFeeds(ownerDid).map((list) { 76 + return list.map(SavedFeedData.fromEntity).toList(); 77 + }); 76 78 } 77 79 } 78 80 ··· 84 86 final authState = ref.watch(authProvider); 85 87 final ownerDid = (authState is AuthStateAuthenticated) ? authState.session.did : null; 86 88 87 - if (ownerDid == null) return const Stream.empty(); 89 + if (ownerDid == null) { 90 + return const Stream.empty(); 91 + } 88 92 89 93 final repository = ref.watch(feedRepositoryProvider); 90 - return repository 91 - .watchPinnedFeeds(ownerDid) 92 - .map((list) => list.map(SavedFeedData.fromEntity).toList()); 94 + return repository.watchPinnedFeeds(ownerDid).map((list) { 95 + return list.map(SavedFeedData.fromEntity).toList(); 96 + }); 93 97 } 94 98 } 95 99
+89 -17
lib/src/features/feeds/infrastructure/feed_repository.dart
··· 132 132 List<String> remotePinnedUris, 133 133 String ownerDid, 134 134 ) async { 135 + _logger.debug( 136 + 'Merging preferences: ${remoteSavedUris.length} saved, ${remotePinnedUris.length} pinned', 137 + ); 138 + final pinnedStr = remotePinnedUris.join(', '); 139 + _logger.debug( 140 + 'Pinned URIs: ${pinnedStr.length > 500 ? pinnedStr.substring(0, 500) : pinnedStr}', 141 + ); 142 + 135 143 final localFeeds = await _dao.getAllFeeds(ownerDid); 136 144 final now = DateTime.now(); 137 145 final remoteSavedSet = remoteSavedUris.toSet(); ··· 151 159 152 160 final Map<String, FeedGenerator> fetchedMetadata = {}; 153 161 if (newRemoteFeeds.isNotEmpty) { 162 + _logger.debug('Need to fetch metadata for ${newRemoteFeeds.length} new feeds'); 154 163 try { 155 164 final batchResults = await getFeedGenerators(newRemoteFeeds); 165 + _logger.debug('Batch fetch returned ${batchResults.length} results'); 156 166 for (final feed in batchResults) { 157 167 fetchedMetadata[feed.uri] = feed; 158 168 } 159 - } catch (e) { 160 - _logger.warning('Failed to batch fetch feed metadata', {'error': e}); 169 + } catch (e, stack) { 170 + _logger.error('Failed to batch fetch feed metadata', {'error': e, 'stack': stack}); 161 171 } 172 + } else { 173 + _logger.debug('No new feeds need metadata fetching'); 162 174 } 163 175 164 176 for (var i = 0; i < remoteSavedUris.length; i++) { ··· 248 260 localUpdatedAt: const Value(null), 249 261 ), 250 262 ); 263 + _logger.debug('Prepared feed for insert', { 264 + 'uri': remoteUri, 265 + 'name': metadata.displayName, 266 + 'isPinned': remoteIsPinned, 267 + }); 251 268 } else { 252 269 _logger.warning('Missing metadata for $remoteUri'); 253 270 } ··· 278 295 } 279 296 } 280 297 298 + _logger.debug( 299 + 'Database operations: ${feedsToInsert.length} inserts, ${feedsToUpdate.length} updates, ${feedsToRemove.length} deletes', 300 + ); 301 + 281 302 if (feedsToInsert.isNotEmpty) { 303 + final pinnedInserts = feedsToInsert.where((f) => f.isPinned.value == true).length; 304 + _logger.debug('Inserting ${feedsToInsert.length} feeds ($pinnedInserts pinned)'); 282 305 await _dao.upsertFeeds(feedsToInsert); 283 306 } 284 - for (final update in feedsToUpdate) { 285 - await _dao.updateSyncState( 286 - uri: update.uri, 287 - ownerDid: ownerDid, 288 - sortOrder: update.sortOrder, 289 - isPinned: update.isPinned, 290 - lastSynced: update.lastSynced, 291 - clearLocalModification: update.clearLocalUpdatedAt, 292 - ); 307 + 308 + if (feedsToUpdate.isNotEmpty) { 309 + final pinnedUpdates = feedsToUpdate.where((u) => u.isPinned).length; 310 + _logger.debug('Updating ${feedsToUpdate.length} feeds ($pinnedUpdates pinned)'); 311 + for (final update in feedsToUpdate) { 312 + await _dao.updateSyncState( 313 + uri: update.uri, 314 + ownerDid: ownerDid, 315 + sortOrder: update.sortOrder, 316 + isPinned: update.isPinned, 317 + lastSynced: update.lastSynced, 318 + clearLocalModification: update.clearLocalUpdatedAt, 319 + ); 320 + } 293 321 } 294 - for (final uri in feedsToRemove) { 295 - await _dao.deleteFeed(uri, ownerDid); 322 + 323 + if (feedsToRemove.isNotEmpty) { 324 + _logger.debug('Deleting ${feedsToRemove.length} feeds'); 325 + for (final uri in feedsToRemove) { 326 + await _dao.deleteFeed(uri, ownerDid); 327 + } 296 328 } 297 329 298 330 for (final uri in feedsToSyncToRemote) { ··· 300 332 if (!existing.any((e) => e.payload == uri && e.type == 'save')) { 301 333 await _syncQueueDao.enqueueFeedSync(type: 'save', feedUri: uri, ownerDid: ownerDid); 302 334 } 335 + } 336 + 337 + final allFeeds = await _dao.getAllFeeds(ownerDid); 338 + final pinnedFeeds = allFeeds.where((f) => f.isPinned).toList(); 339 + _logger.debug('Sync completed: ${allFeeds.length} total, ${pinnedFeeds.length} pinned'); 340 + if (pinnedFeeds.isNotEmpty) { 341 + final pinnedNames = pinnedFeeds.map((f) => '${f.displayName} (${f.uri})').join(', '); 342 + _logger.debug( 343 + 'Pinned feeds: ${pinnedNames.length > 500 ? pinnedNames.substring(0, 500) : pinnedNames}', 344 + ); 345 + } else { 346 + _logger.warning('No pinned feeds found after sync!'); 303 347 } 304 348 } 305 349 ··· 940 984 Future<List<FeedGenerator>> getFeedGenerators(List<String> uris) async { 941 985 if (uris.isEmpty) return []; 942 986 987 + _logger.debug('Batch fetching feed generators', {'count': uris.length}); 988 + 943 989 const batchSize = 25; 944 990 final results = <FeedGenerator>[]; 945 991 ··· 947 993 final end = (i + batchSize < uris.length) ? i + batchSize : uris.length; 948 994 final batch = uris.sublist(i, end); 949 995 996 + _logger.debug('Fetching batch $i-$end', {'batchSize': batch.length, 'uris': batch}); 997 + 950 998 try { 951 999 final response = await _api.call( 952 1000 'app.bsky.feed.getFeedGenerators', 953 1001 params: {'feeds': batch}, 954 1002 ); 955 1003 956 - final views = (response['feeds'] as List).cast<Map<String, dynamic>>(); 957 - results.addAll(views.map((v) => FeedGenerator.fromJson(v))); 958 - } catch (e) { 959 - _logger.error('Batch fetch failed for slice $i-$end', {'error': e}); 1004 + final feedsArray = response['feeds']; 1005 + if (feedsArray is! List) { 1006 + _logger.error('Invalid response structure', { 1007 + 'expected': 'List', 1008 + 'got': feedsArray.runtimeType, 1009 + 'response': response, 1010 + }); 1011 + continue; 1012 + } 1013 + 1014 + final views = feedsArray.cast<Map<String, dynamic>>(); 1015 + final batchResults = views.map((v) => FeedGenerator.fromJson(v)).toList(); 1016 + results.addAll(batchResults); 1017 + 1018 + _logger.debug('Batch $i-$end successful', { 1019 + 'requested': batch.length, 1020 + 'received': batchResults.length, 1021 + }); 1022 + } catch (e, stack) { 1023 + _logger.error('Batch fetch failed for slice $i-$end', { 1024 + 'error': e, 1025 + 'stack': stack, 1026 + 'batchUris': batch, 1027 + }); 960 1028 } 961 1029 } 962 1030 1031 + _logger.debug('Batch fetch complete', { 1032 + 'totalRequested': uris.length, 1033 + 'totalReceived': results.length, 1034 + }); 963 1035 return results; 964 1036 } 965 1037 }
+8 -2
lib/src/infrastructure/db/daos/saved_feeds_dao.dart
··· 71 71 return (select(savedFeeds) 72 72 ..where((t) => t.ownerDid.equals(ownerDid)) 73 73 ..orderBy([(t) => OrderingTerm(expression: t.sortOrder)])) 74 - .watch(); 74 + .watch() 75 + .map((list) { 76 + return list; 77 + }); 75 78 } 76 79 77 80 /// Gets all pinned feeds ordered by sortOrder. ··· 89 92 ..where((t) => t.ownerDid.equals(ownerDid)) 90 93 ..where((t) => t.isPinned.equals(true)) 91 94 ..orderBy([(t) => OrderingTerm(expression: t.sortOrder)])) 92 - .watch(); 95 + .watch() 96 + .map((list) { 97 + return list; 98 + }); 93 99 } 94 100 95 101 /// Gets feeds that haven't been synced recently (stale metadata).
+10
lib/src/infrastructure/network/dio_clients.dart
··· 10 10 /// 11 11 /// This client is used for unauthenticated reads like fetching profiles, threads, and 12 12 /// search results. 13 + /// 14 + /// The [listFormat] is set to [ListFormat.multi] to serialize array query 15 + /// parameters as repeated parameter names (e.g., ?feeds=uri1&feeds=uri2), which is 16 + /// the format expected by AT Protocol endpoints. 13 17 Dio createPublicDio({bool enableLogging = true, List<Interceptor> interceptors = const []}) { 14 18 final dio = Dio( 15 19 BaseOptions( ··· 18 22 receiveTimeout: const Duration(seconds: 30), 19 23 sendTimeout: const Duration(seconds: 30), 20 24 headers: {'Accept': 'application/json', 'Content-Type': 'application/json'}, 25 + listFormat: ListFormat.multi, 21 26 ), 22 27 ); 23 28 ··· 34 39 /// 35 40 /// This client is used for authenticated operations and requires the PDS URL to be provided 36 41 /// (i.e. resolved from user's DID document). 42 + /// 43 + /// The [listFormat] is set to [ListFormat.multi] to serialize array query 44 + /// parameters as repeated parameter names (e.g., ?feeds=uri1&feeds=uri2), which is 45 + /// the format expected by AT Protocol endpoints. 37 46 Dio createPdsDio({ 38 47 required String pdsUrl, 39 48 required SessionGetter getSession, ··· 50 59 receiveTimeout: const Duration(seconds: 30), 51 60 sendTimeout: const Duration(seconds: 30), 52 61 headers: {'Accept': 'application/json', 'Content-Type': 'application/json'}, 62 + listFormat: ListFormat.multi, 53 63 ), 54 64 ); 55 65
+24 -5
lib/src/infrastructure/network/endpoint_registry.dart
··· 62 62 'app.bsky.feed.getFeed': const EndpointMeta( 63 63 nsid: 'app.bsky.feed.getFeed', 64 64 method: HttpMethod.get, 65 - hostKind: HostKind.publicApi, 66 - requiresAuth: false, 65 + hostKind: HostKind.pds, 66 + requiresAuth: true, 67 + ), 68 + 'app.bsky.feed.getListFeed': const EndpointMeta( 69 + nsid: 'app.bsky.feed.getListFeed', 70 + method: HttpMethod.get, 71 + hostKind: HostKind.pds, 72 + requiresAuth: true, 67 73 ), 68 74 'app.bsky.feed.getFeedGenerator': const EndpointMeta( 69 75 nsid: 'app.bsky.feed.getFeedGenerator', 76 + method: HttpMethod.get, 77 + hostKind: HostKind.publicApi, 78 + ), 79 + 'app.bsky.feed.getFeedGenerators': const EndpointMeta( 80 + nsid: 'app.bsky.feed.getFeedGenerators', 70 81 method: HttpMethod.get, 71 82 hostKind: HostKind.publicApi, 72 83 ), ··· 120 131 method: HttpMethod.get, 121 132 hostKind: HostKind.publicApi, 122 133 ), 134 + 'app.bsky.graph.getList': const EndpointMeta( 135 + nsid: 'app.bsky.graph.getList', 136 + method: HttpMethod.get, 137 + hostKind: HostKind.publicApi, 138 + ), 123 139 'app.bsky.graph.muteActor': const EndpointMeta( 124 140 nsid: 'app.bsky.graph.muteActor', 125 141 method: HttpMethod.post, ··· 180 196 'com.atproto.repo.getRecord': const EndpointMeta( 181 197 nsid: 'com.atproto.repo.getRecord', 182 198 method: HttpMethod.get, 183 - hostKind: HostKind.publicApi, 199 + hostKind: HostKind.pds, 200 + requiresAuth: true, 184 201 ), 185 202 'com.atproto.repo.describeRepo': const EndpointMeta( 186 203 nsid: 'com.atproto.repo.describeRepo', 187 204 method: HttpMethod.get, 188 - hostKind: HostKind.publicApi, 205 + hostKind: HostKind.pds, 206 + requiresAuth: true, 189 207 ), 190 208 'com.atproto.repo.listRecords': const EndpointMeta( 191 209 nsid: 'com.atproto.repo.listRecords', 192 210 method: HttpMethod.get, 193 - hostKind: HostKind.publicApi, 211 + hostKind: HostKind.pds, 212 + requiresAuth: true, 194 213 ), 195 214 196 215 'com.atproto.identity.resolveHandle': const EndpointMeta(
+1 -8
test/src/features/developer_tools/infrastructure/devtools_repository_test.dart
··· 27 27 28 28 test('returns list of collections on success', () async { 29 29 final responseData = { 30 - 'collections': [ 31 - {'nsid': 'app.bsky.feed.post', 'count': 42}, 32 - {'nsid': 'app.bsky.actor.profile', 'count': 1}, 33 - {'nsid': 'app.bsky.graph.follow', 'count': 100}, 34 - ], 30 + 'collections': ['app.bsky.feed.post', 'app.bsky.actor.profile', 'app.bsky.graph.follow'], 35 31 }; 36 32 37 33 when( ··· 42 38 43 39 expect(result, hasLength(3)); 44 40 expect(result[0].nsid, 'app.bsky.feed.post'); 45 - expect(result[0].count, 42); 46 41 expect(result[1].nsid, 'app.bsky.actor.profile'); 47 - expect(result[1].count, 1); 48 42 expect(result[2].nsid, 'app.bsky.graph.follow'); 49 - expect(result[2].count, 100); 50 43 51 44 verify( 52 45 () => mockXrpc.call('com.atproto.repo.describeRepo', params: {'repo': testDid}),
+2 -1
test/src/features/developer_tools/presentation/screens/dev_tools_home_page_test.dart
··· 40 40 expect(find.text('https://pds.example.com'), findsOneWidget); 41 41 42 42 expect(find.text('Quick Actions'), findsOneWidget); 43 - expect(find.text('Coming soon...'), findsOneWidget); 43 + expect(find.text('Browse My Repository'), findsOneWidget); 44 + expect(find.text('Explore collections and records'), findsOneWidget); 44 45 }); 45 46 46 47 testWidgets('copies DID to clipboard', (tester) async {
+22
test/src/infrastructure/network/endpoint_registry_test.dart
··· 229 229 expect(meta.proxyKind, equals(ProxyKind.chat), reason: '$nsid should use chat proxy'); 230 230 } 231 231 }); 232 + 233 + test('feed generator endpoints are registered', () { 234 + final feedEndpoints = ['app.bsky.feed.getFeedGenerator', 'app.bsky.feed.getFeedGenerators']; 235 + 236 + for (final nsid in feedEndpoints) { 237 + final meta = registry.get(nsid); 238 + expect(meta.hostKind, equals(HostKind.publicApi), reason: '$nsid should use publicApi'); 239 + expect(meta.requiresAuth, isFalse, reason: '$nsid should not require auth'); 240 + expect(meta.method, equals(HttpMethod.get), reason: '$nsid should be GET'); 241 + } 242 + }); 243 + 244 + test('graph list endpoints are registered', () { 245 + final listEndpoints = ['app.bsky.graph.getList']; 246 + 247 + for (final nsid in listEndpoints) { 248 + final meta = registry.get(nsid); 249 + expect(meta.hostKind, equals(HostKind.publicApi), reason: '$nsid should use publicApi'); 250 + expect(meta.requiresAuth, isFalse, reason: '$nsid should not require auth'); 251 + expect(meta.method, equals(HttpMethod.get), reason: '$nsid should be GET'); 252 + } 253 + }); 232 254 }); 233 255 }