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: list crud ops

+667 -15
+7 -7
docs/tasks/phase-4.md
··· 46 46 47 47 ### List CRUD 48 48 49 - - [ ] Create list — name, description, avatar, purpose selector (curation/moderation) via `com.atproto.repo.createRecord` 50 - - [ ] Edit list — update name, description, avatar via `com.atproto.repo.putRecord` 51 - - [ ] Delete list via `com.atproto.repo.deleteRecord` 52 - - [ ] Add members — search via `searchActorsTypeahead`, create `listitem` records 53 - - [ ] Remove members — delete `listitem` records 49 + - [x] Create list — name, description, avatar, purpose selector (curation/moderation) via `com.atproto.repo.createRecord` 50 + - [x] Edit list — update name, description, avatar via `com.atproto.repo.putRecord` 51 + - [x] Delete list via `com.atproto.repo.deleteRecord` 52 + - [x] Add members — search via `searchActorsTypeahead`, create `listitem` records 53 + - [x] Remove members — delete `listitem` records 54 54 55 55 ### Moderation Actions 56 56 57 - - [ ] Mute list via `muteActorList` / unmute via `unmuteActorList` 58 - - [ ] Block via list — create `listblock` record; unblock — delete `listblock` record 57 + - [x] Mute list via `muteActorList` / unmute via `unmuteActorList` 58 + - [x] Block via list — create `listblock` record; unblock — delete `listblock` record 59 59 60 60 ### Screens 61 61
+46 -1
lib/features/lists/bloc/list_bloc.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 1 + import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; 2 2 import 'package:bluesky/app_bsky_graph_defs.dart'; 3 3 import 'package:equatable/equatable.dart'; 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 19 19 on<ListUnmuted>(_onListUnmuted); 20 20 on<ListBlocked>(_onListBlocked); 21 21 on<ListUnblocked>(_onListUnblocked); 22 + on<ListUpdated>(_onListUpdated); 23 + on<ListDeleted>(_onListDeleted); 22 24 } 23 25 24 26 final ListRepository _listRepository; ··· 123 125 action: () => _listRepository.unblockList(blockUri: blockUri), 124 126 errorPrefix: 'Failed to unblock list', 125 127 ); 128 + } 129 + 130 + Future<void> _onListUpdated(ListUpdated event, Emitter<ListState> emit) async { 131 + if (state.status != ListStatus.loaded || state.listUri == null || state.isMutating || state.list == null) { 132 + return; 133 + } 134 + 135 + emit(state.copyWith(isMutating: true, errorMessage: null)); 136 + 137 + try { 138 + BlobRef? avatarBlob; 139 + if (event.avatarBytes != null) { 140 + avatarBlob = await _listRepository.uploadListAvatar(bytes: event.avatarBytes!, mimeType: event.avatarMimeType); 141 + } 142 + 143 + await _listRepository.updateList( 144 + listUri: state.listUri!, 145 + userDid: event.userDid, 146 + name: event.name, 147 + purpose: state.list!.purpose.toJson(), 148 + description: event.description, 149 + avatarBlob: avatarBlob, 150 + ); 151 + 152 + await _reloadList(emit, isRefreshing: false, isMutating: true, errorPrefix: 'Failed to update list'); 153 + } catch (error) { 154 + emit(state.copyWith(isMutating: false, errorMessage: 'Failed to update list: $error')); 155 + } 156 + } 157 + 158 + Future<void> _onListDeleted(ListDeleted event, Emitter<ListState> emit) async { 159 + if (state.status != ListStatus.loaded || state.listUri == null || state.isMutating) { 160 + return; 161 + } 162 + 163 + emit(state.copyWith(isMutating: true, errorMessage: null)); 164 + 165 + try { 166 + await _listRepository.deleteList(listUri: state.listUri!, userDid: event.userDid); 167 + emit(const ListState.deleted()); 168 + } catch (error) { 169 + emit(state.copyWith(isMutating: false, errorMessage: 'Failed to delete list: $error')); 170 + } 126 171 } 127 172 128 173 Future<void> _runMutation(
+28
lib/features/lists/bloc/list_event.dart
··· 54 54 final class ListUnblocked extends ListEvent { 55 55 const ListUnblocked(); 56 56 } 57 + 58 + final class ListUpdated extends ListEvent { 59 + const ListUpdated({ 60 + required this.userDid, 61 + required this.name, 62 + this.description, 63 + this.avatarBytes, 64 + this.avatarMimeType = 'image/jpeg', 65 + }); 66 + 67 + final String userDid; 68 + final String name; 69 + final String? description; 70 + final List<int>? avatarBytes; 71 + final String avatarMimeType; 72 + 73 + @override 74 + List<Object?> get props => [userDid, name, description, avatarBytes, avatarMimeType]; 75 + } 76 + 77 + final class ListDeleted extends ListEvent { 78 + const ListDeleted({required this.userDid}); 79 + 80 + final String userDid; 81 + 82 + @override 83 + List<Object?> get props => [userDid]; 84 + }
+3 -1
lib/features/lists/bloc/list_state.dart
··· 1 1 part of 'list_bloc.dart'; 2 2 3 - enum ListStatus { initial, loading, loaded, error } 3 + enum ListStatus { initial, loading, loaded, error, deleted } 4 4 5 5 class ListState extends Equatable { 6 6 const ListState._({ ··· 46 46 47 47 const ListState.error({required String message, AtUri? listUri, int limit = 50}) 48 48 : this._(status: ListStatus.error, listUri: listUri, limit: limit, errorMessage: message); 49 + 50 + const ListState.deleted() : this._(status: ListStatus.deleted); 49 51 50 52 final ListStatus status; 51 53 final AtUri? listUri;
+33
lib/features/lists/cubit/my_lists_cubit.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; 1 2 import 'package:bluesky/app_bsky_graph_defs.dart'; 2 3 import 'package:equatable/equatable.dart'; 3 4 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 51 52 ); 52 53 } catch (error) { 53 54 emit(state.copyWith(isRefreshing: false, errorMessage: 'Failed to refresh lists: $error')); 55 + } 56 + } 57 + 58 + Future<AtUri?> createList({ 59 + required String userDid, 60 + required String name, 61 + required String purpose, 62 + String? description, 63 + List<int>? avatarBytes, 64 + String avatarMimeType = 'image/jpeg', 65 + }) async { 66 + try { 67 + BlobRef? avatarBlob; 68 + if (avatarBytes != null) { 69 + avatarBlob = await _listRepository.uploadListAvatar(bytes: avatarBytes, mimeType: avatarMimeType); 70 + } 71 + 72 + final listUri = await _listRepository.createList( 73 + userDid: userDid, 74 + name: name, 75 + purpose: purpose, 76 + description: description, 77 + avatarBlob: avatarBlob, 78 + ); 79 + 80 + if (state.status == MyListsStatus.loaded) { 81 + await refresh(); 82 + } 83 + 84 + return listUri; 85 + } catch (_) { 86 + return null; 54 87 } 55 88 } 56 89
+64 -1
lib/features/lists/data/list_repository.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 1 + import 'dart:typed_data'; 2 + 3 + import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; 2 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 6 import 'package:bluesky/app_bsky_graph_defs.dart'; ··· 116 118 117 119 Future<void> unblockList({required AtUri blockUri}) async { 118 120 await _bluesky.graph.listblock.delete(rkey: blockUri.rkey); 121 + } 122 + 123 + Future<BlobRef?> uploadListAvatar({required List<int> bytes, String mimeType = 'image/jpeg'}) async { 124 + final response = await _bluesky.atproto.repo.uploadBlob( 125 + bytes: Uint8List.fromList(bytes), 126 + $headers: {'Content-Type': mimeType}, 127 + ); 128 + return response.data.blob.ref; 129 + } 130 + 131 + Future<AtUri> createList({ 132 + required String userDid, 133 + required String name, 134 + required String purpose, 135 + String? description, 136 + BlobRef? avatarBlob, 137 + }) async { 138 + final record = <String, dynamic>{ 139 + r'$type': 'app.bsky.graph.list', 140 + 'purpose': purpose, 141 + 'name': name, 142 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 143 + }; 144 + if (description != null) record['description'] = description; 145 + if (avatarBlob != null) record['avatar'] = avatarBlob.toJson(); 146 + 147 + final response = await _bluesky.atproto.repo.createRecord( 148 + repo: userDid, 149 + collection: 'app.bsky.graph.list', 150 + record: record, 151 + ); 152 + return response.data.uri; 153 + } 154 + 155 + Future<void> updateList({ 156 + required AtUri listUri, 157 + required String userDid, 158 + required String name, 159 + required String purpose, 160 + String? description, 161 + BlobRef? avatarBlob, 162 + }) async { 163 + final record = <String, dynamic>{ 164 + r'$type': 'app.bsky.graph.list', 165 + 'purpose': purpose, 166 + 'name': name, 167 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 168 + }; 169 + if (description != null) record['description'] = description; 170 + if (avatarBlob != null) record['avatar'] = avatarBlob.toJson(); 171 + 172 + await _bluesky.atproto.repo.putRecord( 173 + repo: userDid, 174 + collection: 'app.bsky.graph.list', 175 + rkey: listUri.rkey, 176 + record: record, 177 + ); 178 + } 179 + 180 + Future<void> deleteList({required AtUri listUri, required String userDid}) async { 181 + await _bluesky.atproto.repo.deleteRecord(repo: userDid, collection: 'app.bsky.graph.list', rkey: listUri.rkey); 119 182 } 120 183 121 184 List<ListView> _filterLists(List<ListView> lists) {
+222 -1
test/features/lists/bloc/list_bloc_test.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 1 + import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; 2 2 import 'package:bloc_test/bloc_test.dart'; 3 3 import 'package:bluesky/app_bsky_actor_defs.dart'; 4 4 import 'package:bluesky/app_bsky_graph_defs.dart'; ··· 21 21 registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.list/fallback')); 22 22 registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listitem/fallback')); 23 23 registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listblock/fallback')); 24 + registerFallbackValue(const BlobRef(link: 'bafkreifallback')); 24 25 }); 25 26 26 27 setUp(() { ··· 177 178 ); 178 179 179 180 blocTest<ListBloc, ListState>( 181 + 'updates the list metadata and rehydrates', 182 + build: () => ListBloc(listRepository: mockListRepository), 183 + seed: () => 184 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 185 + setUp: () { 186 + when( 187 + () => mockListRepository.updateList( 188 + listUri: listUri, 189 + userDid: 'did:plc:creator', 190 + name: 'Renamed List', 191 + purpose: any(named: 'purpose'), 192 + description: any(named: 'description'), 193 + avatarBlob: any(named: 'avatarBlob'), 194 + ), 195 + ).thenAnswer((_) async {}); 196 + when( 197 + () => mockListRepository.getList( 198 + listUri: listUri, 199 + cursor: any(named: 'cursor'), 200 + limit: 50, 201 + ), 202 + ).thenAnswer( 203 + (_) async => ListDetailResult( 204 + list: _buildListView(listUri: listUri), 205 + items: [firstItem], 206 + cursor: null, 207 + ), 208 + ); 209 + }, 210 + act: (bloc) => 211 + bloc.add(const ListUpdated(userDid: 'did:plc:creator', name: 'Renamed List', description: 'New desc')), 212 + expect: () => [ 213 + predicate<ListState>((state) => state.isMutating && !state.isRefreshing), 214 + predicate<ListState>((state) => !state.isMutating && state.status == ListStatus.loaded), 215 + ], 216 + verify: (_) { 217 + verify( 218 + () => mockListRepository.updateList( 219 + listUri: listUri, 220 + userDid: 'did:plc:creator', 221 + name: 'Renamed List', 222 + purpose: any(named: 'purpose'), 223 + description: 'New desc', 224 + avatarBlob: any(named: 'avatarBlob'), 225 + ), 226 + ).called(1); 227 + }, 228 + ); 229 + 230 + blocTest<ListBloc, ListState>( 231 + 'uploads avatar and updates the list', 232 + build: () => ListBloc(listRepository: mockListRepository), 233 + seed: () => 234 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 235 + setUp: () { 236 + when( 237 + () => mockListRepository.uploadListAvatar( 238 + bytes: any(named: 'bytes'), 239 + mimeType: any(named: 'mimeType'), 240 + ), 241 + ).thenAnswer((_) async => const BlobRef(link: 'bafkreinewavatarblob')); 242 + when( 243 + () => mockListRepository.updateList( 244 + listUri: any(named: 'listUri'), 245 + userDid: any(named: 'userDid'), 246 + name: any(named: 'name'), 247 + purpose: any(named: 'purpose'), 248 + description: any(named: 'description'), 249 + avatarBlob: any(named: 'avatarBlob'), 250 + ), 251 + ).thenAnswer((_) async {}); 252 + when( 253 + () => mockListRepository.getList( 254 + listUri: listUri, 255 + cursor: any(named: 'cursor'), 256 + limit: 50, 257 + ), 258 + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: null)); 259 + }, 260 + act: (bloc) => 261 + bloc.add(const ListUpdated(userDid: 'did:plc:creator', name: 'With Avatar', avatarBytes: [1, 2, 3])), 262 + expect: () => [ 263 + predicate<ListState>((state) => state.isMutating), 264 + predicate<ListState>((state) => !state.isMutating && state.status == ListStatus.loaded), 265 + ], 266 + verify: (_) { 267 + verify(() => mockListRepository.uploadListAvatar(bytes: [1, 2, 3], mimeType: 'image/jpeg')).called(1); 268 + verify( 269 + () => mockListRepository.updateList( 270 + listUri: any(named: 'listUri'), 271 + userDid: any(named: 'userDid'), 272 + name: any(named: 'name'), 273 + purpose: any(named: 'purpose'), 274 + description: any(named: 'description'), 275 + avatarBlob: const BlobRef(link: 'bafkreinewavatarblob'), 276 + ), 277 + ).called(1); 278 + }, 279 + ); 280 + 281 + blocTest<ListBloc, ListState>( 282 + 'deletes the list and emits deleted state', 283 + build: () => ListBloc(listRepository: mockListRepository), 284 + seed: () => 285 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 286 + setUp: () { 287 + when( 288 + () => mockListRepository.deleteList(listUri: listUri, userDid: 'did:plc:creator'), 289 + ).thenAnswer((_) async {}); 290 + }, 291 + act: (bloc) => bloc.add(const ListDeleted(userDid: 'did:plc:creator')), 292 + expect: () => [ 293 + predicate<ListState>((state) => state.isMutating), 294 + predicate<ListState>((state) => state.status == ListStatus.deleted), 295 + ], 296 + verify: (_) { 297 + verify(() => mockListRepository.deleteList(listUri: listUri, userDid: 'did:plc:creator')).called(1); 298 + }, 299 + ); 300 + 301 + blocTest<ListBloc, ListState>( 180 302 'blocks and unblocks the active list', 181 303 build: () => ListBloc(listRepository: mockListRepository), 182 304 seed: () => ··· 209 331 predicate<ListState>((state) => state.list?.viewer?.blocked == blockUri), 210 332 predicate<ListState>((state) => state.isMutating), 211 333 predicate<ListState>((state) => state.list?.viewer?.blocked == null), 334 + ], 335 + ); 336 + 337 + blocTest<ListBloc, ListState>( 338 + 'ListUnblocked is a no-op when the list has no block URI', 339 + build: () => ListBloc(listRepository: mockListRepository), 340 + seed: () => 341 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 342 + act: (bloc) => bloc.add(const ListUnblocked()), 343 + expect: () => [], 344 + ); 345 + 346 + blocTest<ListBloc, ListState>( 347 + 'ListMuted is a no-op when a mutation is already in progress', 348 + build: () => ListBloc(listRepository: mockListRepository), 349 + seed: () => ListState.loaded( 350 + listUri: listUri, 351 + list: initialList, 352 + items: [firstItem], 353 + cursor: null, 354 + hasMore: false, 355 + isMutating: true, 356 + ), 357 + act: (bloc) => bloc.add(const ListMuted()), 358 + expect: () => [], 359 + ); 360 + 361 + blocTest<ListBloc, ListState>( 362 + 'ListBlocked is a no-op when a mutation is already in progress', 363 + build: () => ListBloc(listRepository: mockListRepository), 364 + seed: () => ListState.loaded( 365 + listUri: listUri, 366 + list: initialList, 367 + items: [firstItem], 368 + cursor: null, 369 + hasMore: false, 370 + isMutating: true, 371 + ), 372 + act: (bloc) => bloc.add(const ListBlocked()), 373 + expect: () => [], 374 + ); 375 + 376 + blocTest<ListBloc, ListState>( 377 + 'ListMuted emits error message when muteList throws', 378 + build: () => ListBloc(listRepository: mockListRepository), 379 + seed: () => 380 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 381 + setUp: () { 382 + when(() => mockListRepository.muteList(listUri: listUri)).thenThrow(Exception('network error')); 383 + }, 384 + act: (bloc) => bloc.add(const ListMuted()), 385 + expect: () => [ 386 + predicate<ListState>((state) => state.isMutating && state.errorMessage == null), 387 + predicate<ListState>((state) => !state.isMutating && state.errorMessage != null), 388 + ], 389 + ); 390 + 391 + blocTest<ListBloc, ListState>( 392 + 'ListUnmuted emits error message when unmuteList throws', 393 + build: () => ListBloc(listRepository: mockListRepository), 394 + seed: () => 395 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 396 + setUp: () { 397 + when(() => mockListRepository.unmuteList(listUri: listUri)).thenThrow(Exception('network error')); 398 + }, 399 + act: (bloc) => bloc.add(const ListUnmuted()), 400 + expect: () => [ 401 + predicate<ListState>((state) => state.isMutating && state.errorMessage == null), 402 + predicate<ListState>((state) => !state.isMutating && state.errorMessage != null), 403 + ], 404 + ); 405 + 406 + blocTest<ListBloc, ListState>( 407 + 'ListBlocked emits error message when blockList throws', 408 + build: () => ListBloc(listRepository: mockListRepository), 409 + seed: () => 410 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 411 + setUp: () { 412 + when(() => mockListRepository.blockList(listUri: listUri)).thenThrow(Exception('network error')); 413 + }, 414 + act: (bloc) => bloc.add(const ListBlocked()), 415 + expect: () => [ 416 + predicate<ListState>((state) => state.isMutating && state.errorMessage == null), 417 + predicate<ListState>((state) => !state.isMutating && state.errorMessage != null), 418 + ], 419 + ); 420 + 421 + blocTest<ListBloc, ListState>( 422 + 'ListUnblocked emits error message when unblockList throws', 423 + build: () => ListBloc(listRepository: mockListRepository), 424 + seed: () => 425 + ListState.loaded(listUri: listUri, list: blockedList, items: [firstItem], cursor: null, hasMore: false), 426 + setUp: () { 427 + when(() => mockListRepository.unblockList(blockUri: blockUri)).thenThrow(Exception('network error')); 428 + }, 429 + act: (bloc) => bloc.add(const ListUnblocked()), 430 + expect: () => [ 431 + predicate<ListState>((state) => state.isMutating && state.errorMessage == null), 432 + predicate<ListState>((state) => !state.isMutating && state.errorMessage != null), 212 433 ], 213 434 ); 214 435 });
+104 -1
test/features/lists/cubit/my_lists_cubit_test.dart
··· 1 1 import 'package:bloc_test/bloc_test.dart'; 2 - import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; 3 3 import 'package:bluesky/app_bsky_actor_defs.dart'; 4 4 import 'package:bluesky/app_bsky_graph_defs.dart'; 5 5 import 'package:flutter_test/flutter_test.dart'; ··· 16 16 final curationListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/curation'); 17 17 final moderationListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/moderation'); 18 18 final referenceListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/reference'); 19 + 20 + final newListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/new-list'); 19 21 20 22 setUp(() { 21 23 mockListRepository = MockListRepository(); ··· 85 87 predicate<MyListsState>((state) => !state.isRefreshing && state.lists.length == 2 && !state.hasMore), 86 88 ], 87 89 ); 90 + 91 + blocTest<MyListsCubit, MyListsState>( 92 + 'createList creates the list and refreshes', 93 + build: () => MyListsCubit(listRepository: mockListRepository), 94 + seed: () => MyListsState.loaded(actor: actor, lists: [curationList], cursor: null, hasMore: false), 95 + setUp: () { 96 + when( 97 + () => mockListRepository.createList( 98 + userDid: actor, 99 + name: 'New List', 100 + purpose: 'app.bsky.graph.defs#curatelist', 101 + description: any(named: 'description'), 102 + avatarBlob: any(named: 'avatarBlob'), 103 + ), 104 + ).thenAnswer((_) async => newListUri); 105 + when( 106 + () => mockListRepository.getLists( 107 + actor: actor, 108 + cursor: any(named: 'cursor'), 109 + limit: 50, 110 + includeReference: any(named: 'includeReference'), 111 + ), 112 + ).thenAnswer((_) async => ListsResult(lists: [curationList, moderationList], cursor: null)); 113 + }, 114 + act: (cubit) => cubit.createList(userDid: actor, name: 'New List', purpose: 'app.bsky.graph.defs#curatelist'), 115 + expect: () => [ 116 + predicate<MyListsState>((state) => state.isRefreshing), 117 + predicate<MyListsState>((state) => !state.isRefreshing && state.lists.length == 2), 118 + ], 119 + ); 120 + 121 + test('createList returns the new list URI', () async { 122 + final cubit = MyListsCubit(listRepository: mockListRepository); 123 + 124 + when( 125 + () => mockListRepository.createList( 126 + userDid: actor, 127 + name: 'New List', 128 + purpose: 'app.bsky.graph.defs#curatelist', 129 + description: any(named: 'description'), 130 + avatarBlob: any(named: 'avatarBlob'), 131 + ), 132 + ).thenAnswer((_) async => newListUri); 133 + 134 + final result = await cubit.createList( 135 + userDid: actor, 136 + name: 'New List', 137 + purpose: 'app.bsky.graph.defs#curatelist', 138 + ); 139 + 140 + expect(result, newListUri); 141 + }); 142 + 143 + test('createList uploads avatar when bytes provided', () async { 144 + final cubit = MyListsCubit(listRepository: mockListRepository); 145 + const avatarRef = BlobRef(link: 'bafkreiavatarblob'); 146 + 147 + when( 148 + () => mockListRepository.uploadListAvatar( 149 + bytes: any(named: 'bytes'), 150 + mimeType: any(named: 'mimeType'), 151 + ), 152 + ).thenAnswer((_) async => avatarRef); 153 + when( 154 + () => mockListRepository.createList( 155 + userDid: actor, 156 + name: 'With Avatar', 157 + purpose: 'app.bsky.graph.defs#modlist', 158 + description: any(named: 'description'), 159 + avatarBlob: avatarRef, 160 + ), 161 + ).thenAnswer((_) async => newListUri); 162 + 163 + final result = await cubit.createList( 164 + userDid: actor, 165 + name: 'With Avatar', 166 + purpose: 'app.bsky.graph.defs#modlist', 167 + avatarBytes: [1, 2, 3], 168 + ); 169 + 170 + expect(result, newListUri); 171 + verify(() => mockListRepository.uploadListAvatar(bytes: [1, 2, 3], mimeType: 'image/jpeg')).called(1); 172 + }); 173 + 174 + test('createList returns null on failure', () async { 175 + final cubit = MyListsCubit(listRepository: mockListRepository); 176 + 177 + when( 178 + () => mockListRepository.createList( 179 + userDid: actor, 180 + name: any(named: 'name'), 181 + purpose: any(named: 'purpose'), 182 + description: any(named: 'description'), 183 + avatarBlob: any(named: 'avatarBlob'), 184 + ), 185 + ).thenThrow(Exception('network error')); 186 + 187 + final result = await cubit.createList(userDid: actor, name: 'Broken', purpose: 'app.bsky.graph.defs#curatelist'); 188 + 189 + expect(result, isNull); 190 + }); 88 191 89 192 blocTest<MyListsCubit, MyListsState>( 90 193 'loads more pages for the active actor',
+160 -3
test/features/lists/data/list_repository_test.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 1 + import 'dart:typed_data'; 2 + 3 + import 'package:atproto_core/atproto_core.dart' show AtUri, Blob, BlobRef; 2 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 6 import 'package:bluesky/app_bsky_graph_defs.dart'; ··· 17 19 final listItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); 18 20 final blockUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-1'); 19 21 22 + late _FakeAtprotoService atproto; 23 + 20 24 setUp(() { 21 25 graph = _FakeGraphService(); 22 26 feed = _FakeFeedService(); 23 27 actor = _FakeActorService(); 28 + atproto = _FakeAtprotoService(); 24 29 repository = ListRepository( 25 - bluesky: _FakeBlueskyClient(graph: graph, feed: feed, actor: actor), 30 + bluesky: _FakeBlueskyClient(graph: graph, feed: feed, actor: actor, atproto: atproto), 26 31 ); 27 32 }); 28 33 ··· 136 141 expect(graph.listblock.lastCreatedSubject, listUri); 137 142 expect(graph.listblock.lastDeletedRkey, blockUri.rkey); 138 143 }); 144 + 145 + test('uploadListAvatar uploads bytes and returns BlobRef', () async { 146 + final bytes = [1, 2, 3, 4]; 147 + final ref = await repository.uploadListAvatar(bytes: bytes, mimeType: 'image/png'); 148 + 149 + expect(ref, atproto.repo.uploadedBlobRef); 150 + expect(atproto.repo.lastUploadedBytes, Uint8List.fromList(bytes)); 151 + expect(atproto.repo.lastUploadHeaders, {'Content-Type': 'image/png'}); 152 + }); 153 + 154 + test('createList creates a record and returns the new URI', () async { 155 + final createdUri = await repository.createList( 156 + userDid: 'did:plc:creator', 157 + name: 'My List', 158 + purpose: 'app.bsky.graph.defs#curatelist', 159 + description: 'A great list', 160 + ); 161 + 162 + expect(createdUri, atproto.repo.createdListUri); 163 + expect(atproto.repo.lastCreateRepo, 'did:plc:creator'); 164 + expect(atproto.repo.lastCreateCollection, 'app.bsky.graph.list'); 165 + expect(atproto.repo.lastCreateRecord?[r'$type'], 'app.bsky.graph.list'); 166 + expect(atproto.repo.lastCreateRecord?['name'], 'My List'); 167 + expect(atproto.repo.lastCreateRecord?['purpose'], 'app.bsky.graph.defs#curatelist'); 168 + expect(atproto.repo.lastCreateRecord?['description'], 'A great list'); 169 + }); 170 + 171 + test('createList embeds avatar blob when provided', () async { 172 + const blobRef = BlobRef(link: 'bafkreiavatarblob'); 173 + 174 + await repository.createList( 175 + userDid: 'did:plc:creator', 176 + name: 'List With Avatar', 177 + purpose: 'app.bsky.graph.defs#modlist', 178 + avatarBlob: blobRef, 179 + ); 180 + 181 + expect(atproto.repo.lastCreateRecord?['avatar'], blobRef.toJson()); 182 + }); 183 + 184 + test('updateList puts an updated record', () async { 185 + await repository.updateList( 186 + listUri: listUri, 187 + userDid: 'did:plc:creator', 188 + name: 'Updated Name', 189 + purpose: 'app.bsky.graph.defs#curatelist', 190 + description: 'Updated description', 191 + ); 192 + 193 + expect(atproto.repo.lastPutRepo, 'did:plc:creator'); 194 + expect(atproto.repo.lastPutCollection, 'app.bsky.graph.list'); 195 + expect(atproto.repo.lastPutRkey, listUri.rkey); 196 + expect(atproto.repo.lastPutRecord?['name'], 'Updated Name'); 197 + expect(atproto.repo.lastPutRecord?['description'], 'Updated description'); 198 + }); 199 + 200 + test('deleteList deletes the record by rkey', () async { 201 + await repository.deleteList(listUri: listUri, userDid: 'did:plc:creator'); 202 + 203 + expect(atproto.repo.lastDeleteRepo, 'did:plc:creator'); 204 + expect(atproto.repo.lastDeleteCollection, 'app.bsky.graph.list'); 205 + expect(atproto.repo.lastDeleteRkey, listUri.rkey); 206 + }); 139 207 }); 140 208 } 141 209 ··· 151 219 } 152 220 153 221 class _FakeBlueskyClient { 154 - _FakeBlueskyClient({required this.graph, required this.feed, required this.actor}); 222 + _FakeBlueskyClient({required this.graph, required this.feed, required this.actor, _FakeAtprotoService? atproto}) 223 + : atproto = atproto ?? _FakeAtprotoService(); 155 224 156 225 final _FakeGraphService graph; 157 226 final _FakeFeedService feed; 158 227 final _FakeActorService actor; 228 + final _FakeAtprotoService atproto; 229 + } 230 + 231 + class _FakeAtprotoService { 232 + _FakeAtprotoService() : repo = _FakeRepoService(); 233 + 234 + final _FakeRepoService repo; 235 + } 236 + 237 + class _FakeRepoService { 238 + final AtUri createdListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/created-list'); 239 + final BlobRef uploadedBlobRef = const BlobRef(link: 'bafkreitestblobref'); 240 + 241 + String? lastCreateRepo; 242 + String? lastCreateCollection; 243 + Map<String, dynamic>? lastCreateRecord; 244 + 245 + String? lastPutRepo; 246 + String? lastPutCollection; 247 + String? lastPutRkey; 248 + Map<String, dynamic>? lastPutRecord; 249 + 250 + String? lastDeleteRepo; 251 + String? lastDeleteCollection; 252 + String? lastDeleteRkey; 253 + 254 + Uint8List? lastUploadedBytes; 255 + Map<String, String>? lastUploadHeaders; 256 + 257 + Future<_FakeResponse<_FakeCreateRecordData>> createRecord({ 258 + required String repo, 259 + required String collection, 260 + required Map<String, dynamic> record, 261 + String? rkey, 262 + Map<String, String>? $headers, 263 + }) async { 264 + lastCreateRepo = repo; 265 + lastCreateCollection = collection; 266 + lastCreateRecord = record; 267 + return _FakeResponse(_FakeCreateRecordData(createdListUri)); 268 + } 269 + 270 + Future<_FakeResponse<Object>> putRecord({ 271 + required String repo, 272 + required String collection, 273 + required String rkey, 274 + required Map<String, dynamic> record, 275 + Map<String, String>? $headers, 276 + }) async { 277 + lastPutRepo = repo; 278 + lastPutCollection = collection; 279 + lastPutRkey = rkey; 280 + lastPutRecord = record; 281 + return _FakeResponse(Object()); 282 + } 283 + 284 + Future<_FakeResponse<Object>> deleteRecord({ 285 + required String repo, 286 + required String collection, 287 + required String rkey, 288 + Map<String, String>? $headers, 289 + }) async { 290 + lastDeleteRepo = repo; 291 + lastDeleteCollection = collection; 292 + lastDeleteRkey = rkey; 293 + return _FakeResponse(Object()); 294 + } 295 + 296 + Future<_FakeResponse<_FakeUploadBlobData>> uploadBlob({ 297 + required Uint8List bytes, 298 + Map<String, String>? $headers, 299 + }) async { 300 + lastUploadedBytes = bytes; 301 + lastUploadHeaders = $headers; 302 + return _FakeResponse(_FakeUploadBlobData(Blob(mimeType: 'image/jpeg', size: bytes.length, ref: uploadedBlobRef))); 303 + } 304 + } 305 + 306 + class _FakeCreateRecordData { 307 + const _FakeCreateRecordData(this.uri); 308 + 309 + final AtUri uri; 310 + } 311 + 312 + class _FakeUploadBlobData { 313 + const _FakeUploadBlobData(this.blob); 314 + 315 + final Blob blob; 159 316 } 160 317 161 318 class _FakeGraphService {