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

Configure Feed

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

feat: ProfileContextRepository with blocking and lists

+585 -12
+5 -5
docs/tasks/phase-5.md
··· 89 89 90 90 ### Core - Profile Context Repository 91 91 92 - - [ ] `ProfileContextRepository` - depends on `ConstellationClient` + `Bluesky` 93 - - [ ] `getBlockedByCount(did)` - calls `getBacklinksCount(did, 'app.bsky.graph.block:subject')` 94 - - [ ] `getBlockedByProfiles(did, {cursor})` - calls `getDistinct`, hydrates DIDs via `bluesky.actor.getProfiles` (batched 25), returns `({List<ProfileView> profiles, String? cursor, int total})` 95 - - [ ] `getBlockingProfiles(did, {cursor})` - calls `com.atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.block')`, extracts subject DIDs, hydrates via `getProfiles`, returns same shape 96 - - [ ] `getListsOn(did, {cursor})` - calls `getManyToMany(did, 'app.bsky.graph.listitem:subject', 'list')`, derives list AT-URIs from `otherSubject`, hydrates via `bluesky.graph.getList`, returns `({List<ListView> lists, String? cursor, int total})` 92 + - [x] `ProfileContextRepository` - depends on `ConstellationClient` + `Bluesky` 93 + - [x] `getBlockedByCount(did)` - calls `getBacklinksCount(did, 'app.bsky.graph.block:subject')` 94 + - [x] `getBlockedByProfiles(did, {cursor})` - calls `getDistinct`, hydrates DIDs via `bluesky.actor.getProfiles` (batched 25), returns `({List<ProfileView> profiles, String? cursor, int total})` 95 + - [x] `getBlockingProfiles(did, {cursor})` - calls `com.atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.block')`, extracts subject DIDs, hydrates via `getProfiles`, returns same shape 96 + - [x] `getListsOn(did, {cursor})` - calls `getManyToMany(did, 'app.bsky.graph.listitem:subject', 'list')`, derives list AT-URIs from `otherSubject`, hydrates via `bluesky.graph.getList`, returns `({List<ListView> lists, String? cursor, int total})` 97 97 98 98 ### Cubit 99 99
+7 -7
lib/core/network/constellation_client.dart
··· 8 8 class ConstellationLinkRecord { 9 9 const ConstellationLinkRecord({required this.did, required this.collection, required this.rkey}); 10 10 11 - final String did; 12 - final String collection; 13 - final String rkey; 14 - 15 11 factory ConstellationLinkRecord.fromJson(Map<String, dynamic> json) { 16 12 return ConstellationLinkRecord( 17 13 did: json['did'] as String, ··· 19 15 rkey: json['rkey'] as String, 20 16 ); 21 17 } 18 + 19 + final String did; 20 + final String collection; 21 + final String rkey; 22 22 } 23 23 24 24 class ManyToManyItem { 25 25 const ManyToManyItem({required this.linkRecord, required this.otherSubject}); 26 - 27 - final ConstellationLinkRecord linkRecord; 28 - final String otherSubject; 29 26 30 27 factory ManyToManyItem.fromJson(Map<String, dynamic> json) { 31 28 return ManyToManyItem( ··· 33 30 otherSubject: json['otherSubject'] as String, 34 31 ); 35 32 } 33 + 34 + final ConstellationLinkRecord linkRecord; 35 + final String otherSubject; 36 36 } 37 37 38 38 class ConstellationException implements Exception {
+88
lib/features/profile/data/profile_context_repository.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_graph_defs.dart'; 4 + import 'package:lazurite/core/network/constellation_client.dart'; 5 + 6 + class ProfileContextRepository { 7 + ProfileContextRepository({required dynamic bluesky, required ConstellationClient constellationClient}) 8 + : _bluesky = bluesky, 9 + _constellation = constellationClient; 10 + 11 + final dynamic _bluesky; 12 + final ConstellationClient _constellation; 13 + 14 + /// Returns the number of accounts that have blocked [did]. 15 + Future<int> getBlockedByCount(String did) async { 16 + return _constellation.getBacklinksCount(did, 'app.bsky.graph.block:subject'); 17 + } 18 + 19 + /// Returns a page of profiles that have blocked [did], along with the total 20 + /// count and a cursor for the next page. 21 + Future<({List<ProfileView> profiles, String? cursor, int total})> getBlockedByProfiles( 22 + String did, { 23 + String? cursor, 24 + }) async { 25 + final result = await _constellation.getDistinct( 26 + did, 27 + 'app.bsky.graph.block:subject', 28 + cursor: cursor, 29 + ); 30 + final profiles = await _hydrateProfiles(result.dids); 31 + return (profiles: profiles, cursor: result.cursor, total: result.total); 32 + } 33 + 34 + /// Returns a page of profiles that [did] is blocking, along with a cursor. 35 + /// Uses `com.atproto.repo.listRecords` on the actor's own repo. 36 + /// [total] reflects the number of profiles hydrated in this page. 37 + Future<({List<ProfileView> profiles, String? cursor, int total})> getBlockingProfiles( 38 + String did, { 39 + String? cursor, 40 + }) async { 41 + final response = await _bluesky.atproto.repo.listRecords( 42 + repo: did, 43 + collection: 'app.bsky.graph.block', 44 + limit: 50, 45 + cursor: cursor, 46 + ); 47 + 48 + final subjectDids = (response.data.records as List<dynamic>) 49 + .map((r) => r.value['subject'] as String) 50 + .toList(); 51 + final profiles = await _hydrateProfiles(subjectDids); 52 + return (profiles: profiles, cursor: response.data.cursor as String?, total: profiles.length); 53 + } 54 + 55 + /// Returns a page of lists that [did] is a member of, along with the total 56 + /// count and a cursor for the next page. 57 + Future<({List<ListView> lists, String? cursor, int total})> getListsOn(String did, {String? cursor}) async { 58 + final total = await _constellation.getBacklinksCount(did, 'app.bsky.graph.listitem:subject'); 59 + final result = await _constellation.getManyToMany( 60 + did, 61 + 'app.bsky.graph.listitem:subject', 62 + 'list', 63 + cursor: cursor, 64 + ); 65 + 66 + final lists = await Future.wait( 67 + result.items.map((item) async { 68 + final uri = AtUri.parse(item.otherSubject); 69 + final response = await _bluesky.graph.getList(list: uri, limit: 1); 70 + return response.data.list as ListView; 71 + }), 72 + ); 73 + 74 + return (lists: lists, cursor: result.cursor, total: total); 75 + } 76 + 77 + /// Hydrates [dids] into [ProfileView] objects in batches of 25. 78 + Future<List<ProfileView>> _hydrateProfiles(List<String> dids) async { 79 + if (dids.isEmpty) return []; 80 + final allProfiles = <ProfileView>[]; 81 + for (var i = 0; i < dids.length; i += 25) { 82 + final batch = dids.sublist(i, (i + 25).clamp(0, dids.length)); 83 + final response = await _bluesky.actor.getProfiles(actors: batch); 84 + allProfiles.addAll(response.data.profiles as List<ProfileView>); 85 + } 86 + return allProfiles; 87 + } 88 + }
+485
test/features/profile/data/profile_context_repository_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto_core/atproto_core.dart' show AtUri; 4 + import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:bluesky/app_bsky_graph_defs.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:http/http.dart' as http; 8 + import 'package:http/testing.dart'; 9 + import 'package:lazurite/core/network/constellation_client.dart'; 10 + import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 11 + 12 + // --------------------------------------------------------------------------- 13 + // Helpers 14 + // --------------------------------------------------------------------------- 15 + 16 + ProfileView _buildProfileView(String did, String handle) { 17 + return ProfileView(did: did, handle: handle, indexedAt: DateTime.utc(2026, 1, 1)); 18 + } 19 + 20 + ListView _buildListView(String uriStr, String name) { 21 + return ListView( 22 + uri: AtUri.parse(uriStr), 23 + cid: 'cid-$name', 24 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 25 + name: name, 26 + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsCuratelist), 27 + indexedAt: DateTime.utc(2026, 1, 1), 28 + ); 29 + } 30 + 31 + ConstellationClient _constellationWithResponses(Map<String, dynamic> Function(Uri) handler) { 32 + return ConstellationClient( 33 + httpClient: MockClient((request) async { 34 + final body = handler(request.url); 35 + return http.Response(jsonEncode(body), 200); 36 + }), 37 + ); 38 + } 39 + 40 + class _FakeBluesky { 41 + _FakeBluesky({required this.actor, required this.graph, required this.atproto}); 42 + 43 + final dynamic actor; 44 + final dynamic graph; 45 + final dynamic atproto; 46 + } 47 + 48 + class _FakeAtProto { 49 + _FakeAtProto({required this.repo}); 50 + 51 + final dynamic repo; 52 + } 53 + 54 + class _FakeActorService { 55 + _FakeActorService({required this.profiles}); 56 + 57 + final List<ProfileView> profiles; 58 + 59 + Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ 60 + required List<String> actors, 61 + Map<String, String>? $headers, 62 + }) async { 63 + final matched = profiles.where((p) => actors.contains(p.did) || actors.contains(p.handle)).toList(); 64 + return _FakeResponse(_FakeProfilesOutput(matched)); 65 + } 66 + } 67 + 68 + class _FakeProfilesOutput { 69 + _FakeProfilesOutput(this.profiles); 70 + 71 + final List<ProfileView> profiles; 72 + } 73 + 74 + class _FakeGraphService { 75 + _FakeGraphService({this.lists = const {}}); 76 + 77 + final Map<String, ListView> lists; 78 + 79 + Future<_FakeResponse<_FakeGetListOutput>> getList({ 80 + required AtUri list, 81 + int limit = 50, 82 + Map<String, String>? $headers, 83 + }) async { 84 + final listView = lists[list.toString()]; 85 + if (listView == null) throw Exception('List not found: $list'); 86 + return _FakeResponse(_FakeGetListOutput(listView)); 87 + } 88 + } 89 + 90 + class _FakeGetListOutput { 91 + _FakeGetListOutput(this.list); 92 + 93 + final ListView list; 94 + } 95 + 96 + /// A lightweight stand-in for [RepoListRecordsRecord] with a .value map. 97 + class _FakeRecord { 98 + _FakeRecord(this.value); 99 + 100 + final Map<String, dynamic> value; 101 + } 102 + 103 + class _FakeRepoService { 104 + _FakeRepoService({required this.records, this.cursor}); 105 + 106 + /// Each entry is a raw map like `{'value': {'subject': 'did:plc:x'}}`. 107 + final List<Map<String, dynamic>> records; 108 + final String? cursor; 109 + 110 + Future<_FakeResponse<_FakeListRecordsOutput>> listRecords({ 111 + required String repo, 112 + required String collection, 113 + int limit = 50, 114 + String? cursor, 115 + }) async { 116 + final fakeRecords = records.map((r) => _FakeRecord(r['value'] as Map<String, dynamic>)).toList(); 117 + return _FakeResponse(_FakeListRecordsOutput(records: fakeRecords, cursor: this.cursor)); 118 + } 119 + } 120 + 121 + class _FakeListRecordsOutput { 122 + _FakeListRecordsOutput({required this.records, this.cursor}); 123 + 124 + final List<_FakeRecord> records; 125 + final String? cursor; 126 + } 127 + 128 + class _FakeResponse<T> { 129 + _FakeResponse(this.data); 130 + 131 + final T data; 132 + } 133 + 134 + void main() { 135 + group('ProfileContextRepository', () { 136 + group('getBlockedByCount', () { 137 + test('returns total from constellation getBacklinksCount', () async { 138 + final constellation = _constellationWithResponses((uri) { 139 + expect(uri.path, contains('getBacklinksCount')); 140 + expect(uri.queryParameters['subject'], 'did:plc:alice'); 141 + expect(uri.queryParameters['source'], 'app.bsky.graph.block:subject'); 142 + return {'total': 42}; 143 + }); 144 + 145 + final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); 146 + 147 + final count = await repo.getBlockedByCount('did:plc:alice'); 148 + 149 + expect(count, 42); 150 + }); 151 + 152 + test('returns 0 when no blocks found', () async { 153 + final constellation = _constellationWithResponses((_) => {'total': 0}); 154 + final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); 155 + 156 + expect(await repo.getBlockedByCount('did:plc:nobody'), 0); 157 + }); 158 + }); 159 + 160 + group('getBlockedByProfiles', () { 161 + test('hydrates DIDs returned by getDistinct', () async { 162 + final aliceProfile = _buildProfileView('did:plc:alice', 'alice.bsky.social'); 163 + final bobProfile = _buildProfileView('did:plc:bob', 'bob.bsky.social'); 164 + 165 + final constellation = _constellationWithResponses((uri) { 166 + expect(uri.path, contains('getDistinct')); 167 + return { 168 + 'total': 2, 169 + 'dids': ['did:plc:alice', 'did:plc:bob'], 170 + }; 171 + }); 172 + 173 + final repo = ProfileContextRepository( 174 + bluesky: _buildBluesky(profiles: [aliceProfile, bobProfile]), 175 + constellationClient: constellation, 176 + ); 177 + 178 + final result = await repo.getBlockedByProfiles('did:plc:target'); 179 + 180 + expect(result.total, 2); 181 + expect(result.profiles.length, 2); 182 + expect(result.profiles.map((p) => p.did), containsAll(['did:plc:alice', 'did:plc:bob'])); 183 + expect(result.cursor, isNull); 184 + }); 185 + 186 + test('passes cursor to getDistinct and returns cursor from response', () async { 187 + String? capturedCursor; 188 + final constellation = _constellationWithResponses((uri) { 189 + capturedCursor = uri.queryParameters['cursor']; 190 + return {'total': 10, 'dids': [], 'cursor': 'page2'}; 191 + }); 192 + 193 + final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); 194 + 195 + final result = await repo.getBlockedByProfiles('did:plc:target', cursor: 'page1'); 196 + 197 + expect(capturedCursor, 'page1'); 198 + expect(result.cursor, 'page2'); 199 + }); 200 + 201 + test('hydrates profiles in batches of 25', () async { 202 + final dids = List.generate(30, (i) => 'did:plc:user$i'); 203 + final profiles = dids.map((d) => _buildProfileView(d, '$d.bsky.social')).toList(); 204 + 205 + final batchSizes = <int>[]; 206 + final constellation = _constellationWithResponses((_) => {'total': 30, 'dids': dids}); 207 + 208 + final repo = ProfileContextRepository( 209 + bluesky: _FakeBluesky( 210 + actor: _BatchTrackingActorService(profiles: profiles, batchSizes: batchSizes), 211 + graph: _FakeGraphService(), 212 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 213 + ), 214 + constellationClient: constellation, 215 + ); 216 + 217 + final result = await repo.getBlockedByProfiles('did:plc:target'); 218 + 219 + expect(batchSizes, [25, 5]); 220 + expect(result.profiles.length, 30); 221 + }); 222 + 223 + test('returns empty profiles when no DIDs returned', () async { 224 + final constellation = _constellationWithResponses((_) => {'total': 0, 'dids': []}); 225 + final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); 226 + 227 + final result = await repo.getBlockedByProfiles('did:plc:target'); 228 + 229 + expect(result.profiles, isEmpty); 230 + expect(result.total, 0); 231 + }); 232 + }); 233 + 234 + group('getBlockingProfiles', () { 235 + test('extracts subject DIDs from listRecords and hydrates', () async { 236 + final aliceProfile = _buildProfileView('did:plc:alice', 'alice.bsky.social'); 237 + final blockRecords = [ 238 + { 239 + 'value': {'subject': 'did:plc:alice'}, 240 + }, 241 + ]; 242 + 243 + final repo = ProfileContextRepository( 244 + bluesky: _buildBluesky(profiles: [aliceProfile], blockRecords: blockRecords), 245 + constellationClient: _alwaysThrowConstellation(), 246 + ); 247 + 248 + final result = await repo.getBlockingProfiles('did:plc:actor'); 249 + 250 + expect(result.profiles.length, 1); 251 + expect(result.profiles.first.did, 'did:plc:alice'); 252 + expect(result.total, 1); 253 + }); 254 + 255 + test('returns empty when no block records exist', () async { 256 + final repo = ProfileContextRepository( 257 + bluesky: _buildBluesky(blockRecords: []), 258 + constellationClient: _alwaysThrowConstellation(), 259 + ); 260 + 261 + final result = await repo.getBlockingProfiles('did:plc:actor'); 262 + 263 + expect(result.profiles, isEmpty); 264 + expect(result.total, 0); 265 + expect(result.cursor, isNull); 266 + }); 267 + 268 + test('passes cursor to listRecords and returns cursor from response', () async { 269 + String? capturedCursor; 270 + final repo = ProfileContextRepository( 271 + bluesky: _FakeBluesky( 272 + actor: _FakeActorService(profiles: []), 273 + graph: _FakeGraphService(), 274 + atproto: _FakeAtProto( 275 + repo: _CursorTrackingRepoService( 276 + records: [], 277 + responseCursor: 'next-page', 278 + onListRecords: (cursor) => capturedCursor = cursor, 279 + ), 280 + ), 281 + ), 282 + constellationClient: _alwaysThrowConstellation(), 283 + ); 284 + 285 + final result = await repo.getBlockingProfiles('did:plc:actor', cursor: 'page1'); 286 + 287 + expect(capturedCursor, 'page1'); 288 + expect(result.cursor, 'next-page'); 289 + }); 290 + }); 291 + 292 + group('getListsOn', () { 293 + test('returns lists hydrated from getManyToMany results', () async { 294 + const listUri = 'at://did:plc:owner/app.bsky.graph.list/listkey'; 295 + final listView = _buildListView(listUri, 'My List'); 296 + 297 + final constellation = _constellationWithResponses((uri) { 298 + if (uri.path.contains('getBacklinksCount')) return {'total': 1}; 299 + if (uri.path.contains('getManyToMany')) { 300 + expect(uri.queryParameters['pathToOther'], 'list'); 301 + return { 302 + 'items': [ 303 + { 304 + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'itemrkey'}, 305 + 'otherSubject': listUri, 306 + }, 307 + ], 308 + }; 309 + } 310 + throw Exception('Unexpected endpoint: ${uri.path}'); 311 + }); 312 + 313 + final repo = ProfileContextRepository( 314 + bluesky: _buildBluesky(lists: {listUri: listView}), 315 + constellationClient: constellation, 316 + ); 317 + 318 + final result = await repo.getListsOn('did:plc:target'); 319 + 320 + expect(result.total, 1); 321 + expect(result.lists.length, 1); 322 + expect(result.lists.first.name, 'My List'); 323 + expect(result.cursor, isNull); 324 + }); 325 + 326 + test('returns total from getBacklinksCount for listitem:subject', () async { 327 + String? countSource; 328 + final constellation = _constellationWithResponses((uri) { 329 + if (uri.path.contains('getBacklinksCount')) { 330 + countSource = uri.queryParameters['source']; 331 + return {'total': 7}; 332 + } 333 + return {'items': []}; 334 + }); 335 + 336 + final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); 337 + 338 + final result = await repo.getListsOn('did:plc:target'); 339 + 340 + expect(countSource, 'app.bsky.graph.listitem:subject'); 341 + expect(result.total, 7); 342 + }); 343 + 344 + test('passes cursor to getManyToMany and returns response cursor', () async { 345 + String? capturedCursor; 346 + final constellation = _constellationWithResponses((uri) { 347 + if (uri.path.contains('getBacklinksCount')) return {'total': 0}; 348 + if (uri.path.contains('getManyToMany')) { 349 + capturedCursor = uri.queryParameters['cursor']; 350 + return {'items': [], 'cursor': 'next-page'}; 351 + } 352 + return {}; 353 + }); 354 + 355 + final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); 356 + 357 + final result = await repo.getListsOn('did:plc:target', cursor: 'page1'); 358 + 359 + expect(capturedCursor, 'page1'); 360 + expect(result.cursor, 'next-page'); 361 + }); 362 + 363 + test('returns empty lists when getManyToMany returns no items', () async { 364 + final constellation = _constellationWithResponses((uri) { 365 + if (uri.path.contains('getBacklinksCount')) return {'total': 0}; 366 + return {'items': []}; 367 + }); 368 + 369 + final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); 370 + 371 + final result = await repo.getListsOn('did:plc:target'); 372 + 373 + expect(result.lists, isEmpty); 374 + }); 375 + 376 + test('derives list AT-URI from otherSubject and fetches list metadata', () async { 377 + const listUri = 'at://did:plc:owner/app.bsky.graph.list/abc123'; 378 + final listView = _buildListView(listUri, 'Test List'); 379 + 380 + AtUri? capturedUri; 381 + 382 + final repo = ProfileContextRepository( 383 + bluesky: _FakeBluesky( 384 + actor: _FakeActorService(profiles: []), 385 + graph: _UriCapturingGraphService(lists: {listUri: listView}, onGetList: (u) => capturedUri = u), 386 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 387 + ), 388 + constellationClient: _constellationWithResponses((uri) { 389 + if (uri.path.contains('getBacklinksCount')) return {'total': 1}; 390 + return { 391 + 'items': [ 392 + { 393 + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rk'}, 394 + 'otherSubject': listUri, 395 + }, 396 + ], 397 + }; 398 + }), 399 + ); 400 + 401 + await repo.getListsOn('did:plc:target'); 402 + 403 + expect(capturedUri.toString(), listUri); 404 + }); 405 + }); 406 + }); 407 + } 408 + 409 + _FakeBluesky _buildBluesky({ 410 + List<ProfileView> profiles = const [], 411 + Map<String, ListView> lists = const {}, 412 + List<Map<String, dynamic>> blockRecords = const [], 413 + String? blockRecordsCursor, 414 + }) { 415 + return _FakeBluesky( 416 + actor: _FakeActorService(profiles: profiles), 417 + graph: _FakeGraphService(lists: lists), 418 + atproto: _FakeAtProto( 419 + repo: _FakeRepoService(records: blockRecords, cursor: blockRecordsCursor), 420 + ), 421 + ); 422 + } 423 + 424 + ConstellationClient _alwaysThrowConstellation() { 425 + return ConstellationClient( 426 + httpClient: MockClient((_) async => throw Exception('Constellation should not be called')), 427 + ); 428 + } 429 + 430 + class _BatchTrackingActorService { 431 + _BatchTrackingActorService({required this.profiles, required this.batchSizes}); 432 + 433 + final List<ProfileView> profiles; 434 + final List<int> batchSizes; 435 + 436 + Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ 437 + required List<String> actors, 438 + Map<String, String>? $headers, 439 + }) async { 440 + batchSizes.add(actors.length); 441 + final matched = profiles.where((p) => actors.contains(p.did)).toList(); 442 + return _FakeResponse(_FakeProfilesOutput(matched)); 443 + } 444 + } 445 + 446 + class _CursorTrackingRepoService { 447 + _CursorTrackingRepoService({ 448 + required List<Map<String, dynamic>> records, 449 + required this.responseCursor, 450 + required this.onListRecords, 451 + }) : _records = records; 452 + 453 + final List<Map<String, dynamic>> _records; 454 + final String responseCursor; 455 + final void Function(String?) onListRecords; 456 + 457 + Future<_FakeResponse<_FakeListRecordsOutput>> listRecords({ 458 + required String repo, 459 + required String collection, 460 + int limit = 50, 461 + String? cursor, 462 + }) async { 463 + onListRecords(cursor); 464 + final fakeRecords = _records.map((r) => _FakeRecord(r['value'] as Map<String, dynamic>)).toList(); 465 + return _FakeResponse(_FakeListRecordsOutput(records: fakeRecords, cursor: responseCursor)); 466 + } 467 + } 468 + 469 + class _UriCapturingGraphService { 470 + _UriCapturingGraphService({required this.lists, required this.onGetList}); 471 + 472 + final Map<String, ListView> lists; 473 + final void Function(AtUri) onGetList; 474 + 475 + Future<_FakeResponse<_FakeGetListOutput>> getList({ 476 + required AtUri list, 477 + int limit = 50, 478 + Map<String, String>? $headers, 479 + }) async { 480 + onGetList(list); 481 + final listView = lists[list.toString()]; 482 + if (listView == null) throw Exception('List not found: $list'); 483 + return _FakeResponse(_FakeGetListOutput(listView)); 484 + } 485 + }