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: starter pack management BLoC & Cubit

+1241 -5
+2 -2
docs/tasks/phase-4.md
··· 75 75 76 76 ### Core 77 77 78 - - [ ] `StarterPackBloc` — events: `StarterPackRequested`, `StarterPackCreated`, `StarterPackUpdated`, `StarterPackDeleted`, `MemberAdded`, `MemberRemoved` 79 - - [ ] `ActorStarterPacksCubit` — load starter packs for an actor via `getActorStarterPacks` 78 + - [x] `StarterPackBloc` — events: `StarterPackRequested`, `StarterPackCreated`, `StarterPackUpdated`, `StarterPackDeleted`, `MemberAdded`, `MemberRemoved` 79 + - [x] `ActorStarterPacksCubit` — load starter packs for an actor via `getActorStarterPacks` 80 80 81 81 ### Viewing 82 82
+163
lib/features/starter_packs/bloc/starter_pack_bloc.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_graph_defs.dart'; 3 + import 'package:equatable/equatable.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 6 + 7 + part 'starter_pack_event.dart'; 8 + part 'starter_pack_state.dart'; 9 + 10 + class StarterPackBloc extends Bloc<StarterPackEvent, StarterPackState> { 11 + StarterPackBloc({required StarterPackRepository starterPackRepository}) 12 + : _starterPackRepository = starterPackRepository, 13 + super(const StarterPackState.initial()) { 14 + on<StarterPackRequested>(_onStarterPackRequested); 15 + on<StarterPackCreated>(_onStarterPackCreated); 16 + on<StarterPackUpdated>(_onStarterPackUpdated); 17 + on<StarterPackDeleted>(_onStarterPackDeleted); 18 + on<MemberAdded>(_onMemberAdded); 19 + on<MemberRemoved>(_onMemberRemoved); 20 + } 21 + 22 + final StarterPackRepository _starterPackRepository; 23 + 24 + Future<void> _onStarterPackRequested(StarterPackRequested event, Emitter<StarterPackState> emit) async { 25 + emit(StarterPackState.loading(packUri: event.starterPackUri)); 26 + 27 + try { 28 + final starterPack = await _starterPackRepository.getStarterPack(starterPackUri: event.starterPackUri); 29 + 30 + emit(StarterPackState.loaded(packUri: event.starterPackUri, starterPack: starterPack)); 31 + } catch (error) { 32 + emit(StarterPackState.error(message: 'Failed to load starter pack: $error', packUri: event.starterPackUri)); 33 + } 34 + } 35 + 36 + Future<void> _onStarterPackCreated(StarterPackCreated event, Emitter<StarterPackState> emit) async { 37 + emit(const StarterPackState.loading()); 38 + 39 + try { 40 + final packUri = await _starterPackRepository.createStarterPack( 41 + userDid: event.userDid, 42 + name: event.name, 43 + description: event.description, 44 + memberDids: event.memberDids, 45 + feedUris: event.feedUris, 46 + ); 47 + 48 + final starterPack = await _starterPackRepository.getStarterPack(starterPackUri: packUri); 49 + 50 + emit(StarterPackState.loaded(packUri: packUri, starterPack: starterPack)); 51 + } catch (error) { 52 + emit(StarterPackState.error(message: 'Failed to create starter pack: $error')); 53 + } 54 + } 55 + 56 + Future<void> _onStarterPackUpdated(StarterPackUpdated event, Emitter<StarterPackState> emit) async { 57 + final packUri = state.packUri; 58 + final refListUri = state.starterPack?.list?.uri; 59 + 60 + if (state.status != StarterPackStatus.loaded || packUri == null || refListUri == null || state.isMutating) { 61 + return; 62 + } 63 + 64 + emit(state.copyWith(isMutating: true, errorMessage: null)); 65 + 66 + try { 67 + await _starterPackRepository.updateStarterPack( 68 + packUri: packUri, 69 + referenceListUri: refListUri, 70 + name: event.name, 71 + description: event.description, 72 + feedUris: event.feedUris, 73 + ); 74 + 75 + await _reloadPack(emit, packUri: packUri, errorPrefix: 'Failed to update starter pack'); 76 + } catch (error) { 77 + emit(state.copyWith(isMutating: false, errorMessage: 'Failed to update starter pack: $error')); 78 + } 79 + } 80 + 81 + Future<void> _onStarterPackDeleted(StarterPackDeleted event, Emitter<StarterPackState> emit) async { 82 + final packUri = state.packUri; 83 + final refListUri = state.starterPack?.list?.uri; 84 + 85 + if (state.status != StarterPackStatus.loaded || packUri == null || refListUri == null || state.isMutating) { 86 + return; 87 + } 88 + 89 + emit(state.copyWith(isMutating: true, errorMessage: null)); 90 + 91 + try { 92 + await _starterPackRepository.deleteStarterPack( 93 + packUri: packUri, 94 + referenceListUri: refListUri, 95 + userDid: event.userDid, 96 + ); 97 + 98 + emit(const StarterPackState.deleted()); 99 + } catch (error) { 100 + emit(state.copyWith(isMutating: false, errorMessage: 'Failed to delete starter pack: $error')); 101 + } 102 + } 103 + 104 + Future<void> _onMemberAdded(MemberAdded event, Emitter<StarterPackState> emit) async { 105 + final packUri = state.packUri; 106 + final refListUri = state.starterPack?.list?.uri; 107 + 108 + if (state.status != StarterPackStatus.loaded || packUri == null || refListUri == null || state.isMutating) { 109 + return; 110 + } 111 + 112 + await _runMutation( 113 + emit, 114 + packUri: packUri, 115 + action: () => _starterPackRepository.addMember(listUri: refListUri, subjectDid: event.subjectDid), 116 + errorPrefix: 'Failed to add member', 117 + ); 118 + } 119 + 120 + Future<void> _onMemberRemoved(MemberRemoved event, Emitter<StarterPackState> emit) async { 121 + final packUri = state.packUri; 122 + 123 + if (state.status != StarterPackStatus.loaded || packUri == null || state.isMutating) { 124 + return; 125 + } 126 + 127 + await _runMutation( 128 + emit, 129 + packUri: packUri, 130 + action: () => _starterPackRepository.removeMember(listItemUri: event.listItemUri), 131 + errorPrefix: 'Failed to remove member', 132 + ); 133 + } 134 + 135 + Future<void> _runMutation( 136 + Emitter<StarterPackState> emit, { 137 + required AtUri packUri, 138 + required Future<void> Function() action, 139 + required String errorPrefix, 140 + }) async { 141 + emit(state.copyWith(isMutating: true, errorMessage: null)); 142 + 143 + try { 144 + await action(); 145 + await _reloadPack(emit, packUri: packUri, errorPrefix: errorPrefix); 146 + } catch (error) { 147 + emit(state.copyWith(isMutating: false, errorMessage: '$errorPrefix: $error')); 148 + } 149 + } 150 + 151 + Future<void> _reloadPack( 152 + Emitter<StarterPackState> emit, { 153 + required AtUri packUri, 154 + required String errorPrefix, 155 + }) async { 156 + try { 157 + final starterPack = await _starterPackRepository.getStarterPack(starterPackUri: packUri); 158 + emit(StarterPackState.loaded(packUri: packUri, starterPack: starterPack)); 159 + } catch (error) { 160 + emit(state.copyWith(isMutating: false, isRefreshing: false, errorMessage: '$errorPrefix: $error')); 161 + } 162 + } 163 + }
+74
lib/features/starter_packs/bloc/starter_pack_event.dart
··· 1 + part of 'starter_pack_bloc.dart'; 2 + 3 + sealed class StarterPackEvent extends Equatable { 4 + const StarterPackEvent(); 5 + 6 + @override 7 + List<Object?> get props => []; 8 + } 9 + 10 + final class StarterPackRequested extends StarterPackEvent { 11 + const StarterPackRequested({required this.starterPackUri}); 12 + 13 + final AtUri starterPackUri; 14 + 15 + @override 16 + List<Object?> get props => [starterPackUri]; 17 + } 18 + 19 + final class StarterPackCreated extends StarterPackEvent { 20 + const StarterPackCreated({ 21 + required this.userDid, 22 + required this.name, 23 + this.description, 24 + this.memberDids = const [], 25 + this.feedUris = const [], 26 + }); 27 + 28 + final String userDid; 29 + final String name; 30 + final String? description; 31 + final List<String> memberDids; 32 + final List<AtUri> feedUris; 33 + 34 + @override 35 + List<Object?> get props => [userDid, name, description, memberDids, feedUris]; 36 + } 37 + 38 + final class StarterPackUpdated extends StarterPackEvent { 39 + const StarterPackUpdated({required this.name, this.description, this.feedUris = const []}); 40 + 41 + final String name; 42 + final String? description; 43 + final List<AtUri> feedUris; 44 + 45 + @override 46 + List<Object?> get props => [name, description, feedUris]; 47 + } 48 + 49 + final class StarterPackDeleted extends StarterPackEvent { 50 + const StarterPackDeleted({required this.userDid}); 51 + 52 + final String userDid; 53 + 54 + @override 55 + List<Object?> get props => [userDid]; 56 + } 57 + 58 + final class MemberAdded extends StarterPackEvent { 59 + const MemberAdded({required this.subjectDid}); 60 + 61 + final String subjectDid; 62 + 63 + @override 64 + List<Object?> get props => [subjectDid]; 65 + } 66 + 67 + final class MemberRemoved extends StarterPackEvent { 68 + const MemberRemoved({required this.listItemUri}); 69 + 70 + final AtUri listItemUri; 71 + 72 + @override 73 + List<Object?> get props => [listItemUri]; 74 + }
+71
lib/features/starter_packs/bloc/starter_pack_state.dart
··· 1 + part of 'starter_pack_bloc.dart'; 2 + 3 + enum StarterPackStatus { initial, loading, loaded, error, deleted } 4 + 5 + class StarterPackState extends Equatable { 6 + const StarterPackState._({ 7 + required this.status, 8 + this.packUri, 9 + this.starterPack, 10 + this.isRefreshing = false, 11 + this.isMutating = false, 12 + this.errorMessage, 13 + }); 14 + 15 + const StarterPackState.initial() : this._(status: StarterPackStatus.initial); 16 + 17 + const StarterPackState.loading({AtUri? packUri}) : this._(status: StarterPackStatus.loading, packUri: packUri); 18 + 19 + const StarterPackState.loaded({ 20 + required AtUri packUri, 21 + required StarterPackView starterPack, 22 + bool isRefreshing = false, 23 + bool isMutating = false, 24 + String? errorMessage, 25 + }) : this._( 26 + status: StarterPackStatus.loaded, 27 + packUri: packUri, 28 + starterPack: starterPack, 29 + isRefreshing: isRefreshing, 30 + isMutating: isMutating, 31 + errorMessage: errorMessage, 32 + ); 33 + 34 + const StarterPackState.error({required String message, AtUri? packUri}) 35 + : this._(status: StarterPackStatus.error, packUri: packUri, errorMessage: message); 36 + 37 + const StarterPackState.deleted() : this._(status: StarterPackStatus.deleted); 38 + 39 + final StarterPackStatus status; 40 + final AtUri? packUri; 41 + final StarterPackView? starterPack; 42 + final bool isRefreshing; 43 + final bool isMutating; 44 + final String? errorMessage; 45 + 46 + bool get isLoading => status == StarterPackStatus.loading; 47 + bool get hasError => status == StarterPackStatus.error || errorMessage != null; 48 + 49 + StarterPackState copyWith({ 50 + StarterPackStatus? status, 51 + Object? packUri = _starterPackNoValue, 52 + Object? starterPack = _starterPackNoValue, 53 + bool? isRefreshing, 54 + bool? isMutating, 55 + Object? errorMessage = _starterPackNoValue, 56 + }) { 57 + return StarterPackState._( 58 + status: status ?? this.status, 59 + packUri: packUri == _starterPackNoValue ? this.packUri : packUri as AtUri?, 60 + starterPack: starterPack == _starterPackNoValue ? this.starterPack : starterPack as StarterPackView?, 61 + isRefreshing: isRefreshing ?? this.isRefreshing, 62 + isMutating: isMutating ?? this.isMutating, 63 + errorMessage: errorMessage == _starterPackNoValue ? this.errorMessage : errorMessage as String?, 64 + ); 65 + } 66 + 67 + @override 68 + List<Object?> get props => [status, packUri, starterPack, isRefreshing, isMutating, errorMessage]; 69 + } 70 + 71 + const _starterPackNoValue = Object();
+86
lib/features/starter_packs/cubit/actor_starter_packs_cubit.dart
··· 1 + import 'package:bluesky/app_bsky_graph_defs.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 5 + 6 + part 'actor_starter_packs_state.dart'; 7 + 8 + class ActorStarterPacksCubit extends Cubit<ActorStarterPacksState> { 9 + ActorStarterPacksCubit({required StarterPackRepository starterPackRepository}) 10 + : _starterPackRepository = starterPackRepository, 11 + super(const ActorStarterPacksState.initial()); 12 + 13 + final StarterPackRepository _starterPackRepository; 14 + 15 + Future<void> load({required String actor, int limit = 50}) async { 16 + emit(ActorStarterPacksState.loading(actor: actor, limit: limit)); 17 + 18 + try { 19 + final result = await _starterPackRepository.getActorStarterPacks(actor: actor, limit: limit); 20 + emit( 21 + ActorStarterPacksState.loaded( 22 + actor: actor, 23 + starterPacks: result.starterPacks, 24 + cursor: result.cursor, 25 + hasMore: result.cursor != null, 26 + limit: limit, 27 + ), 28 + ); 29 + } catch (error) { 30 + emit(ActorStarterPacksState.error(message: 'Failed to load starter packs: $error', actor: actor, limit: limit)); 31 + } 32 + } 33 + 34 + Future<void> refresh() async { 35 + if (state.actor == null) { 36 + return; 37 + } 38 + 39 + emit(state.copyWith(isRefreshing: true, errorMessage: null)); 40 + 41 + try { 42 + final result = await _starterPackRepository.getActorStarterPacks(actor: state.actor!, limit: state.limit); 43 + emit( 44 + ActorStarterPacksState.loaded( 45 + actor: state.actor!, 46 + starterPacks: result.starterPacks, 47 + cursor: result.cursor, 48 + hasMore: result.cursor != null, 49 + limit: state.limit, 50 + ), 51 + ); 52 + } catch (error) { 53 + emit(state.copyWith(isRefreshing: false, errorMessage: 'Failed to refresh starter packs: $error')); 54 + } 55 + } 56 + 57 + Future<void> loadMore() async { 58 + if (state.status != ActorStarterPacksStatus.loaded || 59 + state.actor == null || 60 + state.cursor == null || 61 + state.isLoadingMore) { 62 + return; 63 + } 64 + 65 + emit(state.copyWith(isLoadingMore: true)); 66 + 67 + try { 68 + final result = await _starterPackRepository.getActorStarterPacks( 69 + actor: state.actor!, 70 + cursor: state.cursor, 71 + limit: state.limit, 72 + ); 73 + 74 + emit( 75 + state.copyWith( 76 + starterPacks: [...state.starterPacks, ...result.starterPacks], 77 + cursor: result.cursor, 78 + hasMore: result.cursor != null, 79 + isLoadingMore: false, 80 + ), 81 + ); 82 + } catch (_) { 83 + emit(state.copyWith(isLoadingMore: false, hasMore: false)); 84 + } 85 + } 86 + }
+95
lib/features/starter_packs/cubit/actor_starter_packs_state.dart
··· 1 + part of 'actor_starter_packs_cubit.dart'; 2 + 3 + enum ActorStarterPacksStatus { initial, loading, loaded, error } 4 + 5 + class ActorStarterPacksState extends Equatable { 6 + const ActorStarterPacksState._({ 7 + required this.status, 8 + this.actor, 9 + this.starterPacks = const [], 10 + this.cursor, 11 + this.hasMore = false, 12 + this.limit = 50, 13 + this.isRefreshing = false, 14 + this.isLoadingMore = false, 15 + this.errorMessage, 16 + }); 17 + 18 + const ActorStarterPacksState.initial() : this._(status: ActorStarterPacksStatus.initial); 19 + 20 + const ActorStarterPacksState.loading({required String actor, int limit = 50}) 21 + : this._(status: ActorStarterPacksStatus.loading, actor: actor, limit: limit); 22 + 23 + const ActorStarterPacksState.loaded({ 24 + required String actor, 25 + required List<StarterPackViewBasic> starterPacks, 26 + String? cursor, 27 + required bool hasMore, 28 + int limit = 50, 29 + bool isRefreshing = false, 30 + bool isLoadingMore = false, 31 + String? errorMessage, 32 + }) : this._( 33 + status: ActorStarterPacksStatus.loaded, 34 + actor: actor, 35 + starterPacks: starterPacks, 36 + cursor: cursor, 37 + hasMore: hasMore, 38 + limit: limit, 39 + isRefreshing: isRefreshing, 40 + isLoadingMore: isLoadingMore, 41 + errorMessage: errorMessage, 42 + ); 43 + 44 + const ActorStarterPacksState.error({required String message, String? actor, int limit = 50}) 45 + : this._(status: ActorStarterPacksStatus.error, actor: actor, limit: limit, errorMessage: message); 46 + 47 + final ActorStarterPacksStatus status; 48 + final String? actor; 49 + final List<StarterPackViewBasic> starterPacks; 50 + final String? cursor; 51 + final bool hasMore; 52 + final int limit; 53 + final bool isRefreshing; 54 + final bool isLoadingMore; 55 + final String? errorMessage; 56 + 57 + ActorStarterPacksState copyWith({ 58 + ActorStarterPacksStatus? status, 59 + Object? actor = _actorStarterPacksNoValue, 60 + List<StarterPackViewBasic>? starterPacks, 61 + Object? cursor = _actorStarterPacksNoValue, 62 + bool? hasMore, 63 + int? limit, 64 + bool? isRefreshing, 65 + bool? isLoadingMore, 66 + Object? errorMessage = _actorStarterPacksNoValue, 67 + }) { 68 + return ActorStarterPacksState._( 69 + status: status ?? this.status, 70 + actor: actor == _actorStarterPacksNoValue ? this.actor : actor as String?, 71 + starterPacks: starterPacks ?? this.starterPacks, 72 + cursor: cursor == _actorStarterPacksNoValue ? this.cursor : cursor as String?, 73 + hasMore: hasMore ?? this.hasMore, 74 + limit: limit ?? this.limit, 75 + isRefreshing: isRefreshing ?? this.isRefreshing, 76 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 77 + errorMessage: errorMessage == _actorStarterPacksNoValue ? this.errorMessage : errorMessage as String?, 78 + ); 79 + } 80 + 81 + @override 82 + List<Object?> get props => [ 83 + status, 84 + actor, 85 + starterPacks, 86 + cursor, 87 + hasMore, 88 + limit, 89 + isRefreshing, 90 + isLoadingMore, 91 + errorMessage, 92 + ]; 93 + } 94 + 95 + const _actorStarterPacksNoValue = Object();
+131
lib/features/starter_packs/data/starter_pack_repository.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_graph_defs.dart'; 3 + import 'package:bluesky/app_bsky_graph_starterpack.dart'; 4 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 5 + 6 + class StarterPackRepository { 7 + StarterPackRepository({required dynamic bluesky, ModerationService? moderationService}) 8 + : _bluesky = bluesky, 9 + _moderationService = moderationService; 10 + 11 + final dynamic _bluesky; 12 + final ModerationService? _moderationService; 13 + 14 + Future<ActorStarterPacksResult> getActorStarterPacks({required String actor, String? cursor, int limit = 50}) async { 15 + final response = await _bluesky.graph.getActorStarterPacks( 16 + actor: actor, 17 + cursor: cursor, 18 + limit: limit, 19 + $headers: await _moderationService?.headersForRequest(), 20 + ); 21 + 22 + return ActorStarterPacksResult(starterPacks: response.data.starterPacks, cursor: response.data.cursor); 23 + } 24 + 25 + Future<StarterPackView> getStarterPack({required AtUri starterPackUri}) async { 26 + final response = await _bluesky.graph.getStarterPack( 27 + starterPack: starterPackUri, 28 + $headers: await _moderationService?.headersForRequest(), 29 + ); 30 + 31 + return response.data.starterPack; 32 + } 33 + 34 + /// Creates a starter pack using the 3-step flow: 35 + /// 1. Create a reference list 36 + /// 2. Add members as listitem records 37 + /// 3. Create the starter pack record pointing at the reference list 38 + Future<AtUri> createStarterPack({ 39 + required String userDid, 40 + required String name, 41 + String? description, 42 + List<String> memberDids = const [], 43 + List<AtUri> feedUris = const [], 44 + }) async { 45 + final refListUri = await _createReferenceList(userDid: userDid); 46 + 47 + for (final did in memberDids) { 48 + await addMember(listUri: refListUri, subjectDid: did); 49 + } 50 + 51 + final feeds = feedUris.map((uri) => FeedItem(uri: uri)).toList(); 52 + 53 + final response = await _bluesky.graph.starterpack.create( 54 + name: name, 55 + description: description, 56 + list: refListUri, 57 + feeds: feeds.isEmpty ? null : feeds, 58 + createdAt: DateTime.now(), 59 + ); 60 + 61 + return response.data.uri; 62 + } 63 + 64 + Future<void> updateStarterPack({ 65 + required AtUri packUri, 66 + required AtUri referenceListUri, 67 + required String name, 68 + String? description, 69 + List<AtUri> feedUris = const [], 70 + }) async { 71 + final feeds = feedUris.map((uri) => FeedItem(uri: uri)).toList(); 72 + 73 + await _bluesky.graph.starterpack.put( 74 + rkey: packUri.rkey, 75 + name: name, 76 + description: description, 77 + list: referenceListUri, 78 + feeds: feeds.isEmpty ? null : feeds, 79 + createdAt: DateTime.now(), 80 + ); 81 + } 82 + 83 + Future<void> deleteStarterPack({ 84 + required AtUri packUri, 85 + required AtUri referenceListUri, 86 + required String userDid, 87 + }) async { 88 + await _bluesky.graph.starterpack.delete(rkey: packUri.rkey); 89 + await _bluesky.atproto.repo.deleteRecord( 90 + repo: userDid, 91 + collection: 'app.bsky.graph.list', 92 + rkey: referenceListUri.rkey, 93 + ); 94 + } 95 + 96 + Future<String> addMember({required AtUri listUri, required String subjectDid}) async { 97 + final response = await _bluesky.graph.listitem.create( 98 + list: listUri, 99 + subject: subjectDid, 100 + createdAt: DateTime.now(), 101 + ); 102 + 103 + return response.data.uri.toString(); 104 + } 105 + 106 + Future<void> removeMember({required AtUri listItemUri}) async { 107 + await _bluesky.graph.listitem.delete(rkey: listItemUri.rkey); 108 + } 109 + 110 + Future<AtUri> _createReferenceList({required String userDid}) async { 111 + final response = await _bluesky.atproto.repo.createRecord( 112 + repo: userDid, 113 + collection: 'app.bsky.graph.list', 114 + record: <String, dynamic>{ 115 + r'$type': 'app.bsky.graph.list', 116 + 'purpose': 'app.bsky.graph.defs#referencelist', 117 + 'name': 'Starter Pack Members', 118 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 119 + }, 120 + ); 121 + 122 + return response.data.uri; 123 + } 124 + } 125 + 126 + class ActorStarterPacksResult { 127 + const ActorStarterPacksResult({required this.starterPacks, this.cursor}); 128 + 129 + final List<StarterPackViewBasic> starterPacks; 130 + final String? cursor; 131 + }
+2 -2
test/features/lists/presentation/list_detail_screen_test.dart
··· 104 104 }); 105 105 106 106 await tester.pumpWidget(buildSubject()); 107 - await tester.pump(); // allow bloc event to be processed 107 + await tester.pump(); 108 108 expect(find.byType(CircularProgressIndicator), findsWidgets); 109 - await tester.pump(const Duration(hours: 2)); // drain pending timers 109 + await tester.pump(const Duration(hours: 2)); 110 110 }); 111 111 112 112 testWidgets('shows list name in app bar after loading', (tester) async {
+1 -1
test/features/lists/presentation/my_lists_screen_test.dart
··· 90 90 91 91 await tester.pumpWidget(buildSubject()); 92 92 expect(find.byType(CircularProgressIndicator), findsOneWidget); 93 - await tester.pump(const Duration(hours: 2)); // drain pending timers 93 + await tester.pump(const Duration(hours: 2)); 94 94 }); 95 95 96 96 testWidgets('shows FEEDS and MODERATION tabs', (tester) async {
+418
test/features/starter_packs/bloc/starter_pack_bloc_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bloc_test/bloc_test.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_graph_defs.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 7 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockStarterPackRepository extends Mock implements StarterPackRepository {} 11 + 12 + void main() { 13 + late MockStarterPackRepository mockRepository; 14 + 15 + final packUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'); 16 + final refListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/ref-list-1'); 17 + final itemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); 18 + 19 + setUpAll(() { 20 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.starterpack/fallback')); 21 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.list/fallback')); 22 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listitem/fallback')); 23 + }); 24 + 25 + setUp(() { 26 + mockRepository = MockStarterPackRepository(); 27 + }); 28 + 29 + group('StarterPackBloc', () { 30 + final starterPack = _buildStarterPackView(packUri: packUri, refListUri: refListUri); 31 + 32 + blocTest<StarterPackBloc, StarterPackState>( 33 + 'loads a starter pack', 34 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 35 + setUp: () { 36 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); 37 + }, 38 + act: (bloc) => bloc.add(StarterPackRequested(starterPackUri: packUri)), 39 + expect: () => [ 40 + StarterPackState.loading(packUri: packUri), 41 + StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 42 + ], 43 + ); 44 + 45 + blocTest<StarterPackBloc, StarterPackState>( 46 + 'emits error when load fails', 47 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 48 + setUp: () { 49 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenThrow(Exception('network error')); 50 + }, 51 + act: (bloc) => bloc.add(StarterPackRequested(starterPackUri: packUri)), 52 + expect: () => [ 53 + StarterPackState.loading(packUri: packUri), 54 + predicate<StarterPackState>((state) => state.status == StarterPackStatus.error && state.errorMessage != null), 55 + ], 56 + ); 57 + 58 + blocTest<StarterPackBloc, StarterPackState>( 59 + 'creates a starter pack and emits loaded state', 60 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 61 + setUp: () { 62 + when( 63 + () => mockRepository.createStarterPack( 64 + userDid: 'did:plc:creator', 65 + name: 'My Pack', 66 + description: any(named: 'description'), 67 + memberDids: any(named: 'memberDids'), 68 + feedUris: any(named: 'feedUris'), 69 + ), 70 + ).thenAnswer((_) async => packUri); 71 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); 72 + }, 73 + act: (bloc) => bloc.add(const StarterPackCreated(userDid: 'did:plc:creator', name: 'My Pack')), 74 + expect: () => [ 75 + const StarterPackState.loading(), 76 + StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 77 + ], 78 + verify: (_) { 79 + verify( 80 + () => mockRepository.createStarterPack( 81 + userDid: 'did:plc:creator', 82 + name: 'My Pack', 83 + description: any(named: 'description'), 84 + memberDids: any(named: 'memberDids'), 85 + feedUris: any(named: 'feedUris'), 86 + ), 87 + ).called(1); 88 + }, 89 + ); 90 + 91 + blocTest<StarterPackBloc, StarterPackState>( 92 + 'creates a starter pack with members and feeds', 93 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 94 + setUp: () { 95 + when( 96 + () => mockRepository.createStarterPack( 97 + userDid: 'did:plc:creator', 98 + name: 'Full Pack', 99 + description: 'A description', 100 + memberDids: ['did:plc:member-1', 'did:plc:member-2'], 101 + feedUris: any(named: 'feedUris'), 102 + ), 103 + ).thenAnswer((_) async => packUri); 104 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); 105 + }, 106 + act: (bloc) => bloc.add( 107 + const StarterPackCreated( 108 + userDid: 'did:plc:creator', 109 + name: 'Full Pack', 110 + description: 'A description', 111 + memberDids: ['did:plc:member-1', 'did:plc:member-2'], 112 + ), 113 + ), 114 + expect: () => [ 115 + const StarterPackState.loading(), 116 + StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 117 + ], 118 + ); 119 + 120 + blocTest<StarterPackBloc, StarterPackState>( 121 + 'emits error when create fails', 122 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 123 + setUp: () { 124 + when( 125 + () => mockRepository.createStarterPack( 126 + userDid: any(named: 'userDid'), 127 + name: any(named: 'name'), 128 + description: any(named: 'description'), 129 + memberDids: any(named: 'memberDids'), 130 + feedUris: any(named: 'feedUris'), 131 + ), 132 + ).thenThrow(Exception('network error')); 133 + }, 134 + act: (bloc) => bloc.add(const StarterPackCreated(userDid: 'did:plc:creator', name: 'My Pack')), 135 + expect: () => [ 136 + const StarterPackState.loading(), 137 + predicate<StarterPackState>((state) => state.status == StarterPackStatus.error && state.errorMessage != null), 138 + ], 139 + ); 140 + 141 + blocTest<StarterPackBloc, StarterPackState>( 142 + 'updates a starter pack and reloads', 143 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 144 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 145 + setUp: () { 146 + when( 147 + () => mockRepository.updateStarterPack( 148 + packUri: packUri, 149 + referenceListUri: refListUri, 150 + name: 'Updated Name', 151 + description: any(named: 'description'), 152 + feedUris: any(named: 'feedUris'), 153 + ), 154 + ).thenAnswer((_) async {}); 155 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); 156 + }, 157 + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), 158 + expect: () => [ 159 + predicate<StarterPackState>((state) => state.isMutating && !state.isRefreshing), 160 + predicate<StarterPackState>((state) => !state.isMutating && state.status == StarterPackStatus.loaded), 161 + ], 162 + verify: (_) { 163 + verify( 164 + () => mockRepository.updateStarterPack( 165 + packUri: packUri, 166 + referenceListUri: refListUri, 167 + name: 'Updated Name', 168 + description: any(named: 'description'), 169 + feedUris: any(named: 'feedUris'), 170 + ), 171 + ).called(1); 172 + }, 173 + ); 174 + 175 + blocTest<StarterPackBloc, StarterPackState>( 176 + 'StarterPackUpdated emits error when update fails', 177 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 178 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 179 + setUp: () { 180 + when( 181 + () => mockRepository.updateStarterPack( 182 + packUri: any(named: 'packUri'), 183 + referenceListUri: any(named: 'referenceListUri'), 184 + name: any(named: 'name'), 185 + description: any(named: 'description'), 186 + feedUris: any(named: 'feedUris'), 187 + ), 188 + ).thenThrow(Exception('network error')); 189 + }, 190 + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), 191 + expect: () => [ 192 + predicate<StarterPackState>((state) => state.isMutating && state.errorMessage == null), 193 + predicate<StarterPackState>((state) => !state.isMutating && state.errorMessage != null), 194 + ], 195 + ); 196 + 197 + blocTest<StarterPackBloc, StarterPackState>( 198 + 'StarterPackUpdated is a no-op when not loaded', 199 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 200 + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), 201 + expect: () => [], 202 + ); 203 + 204 + blocTest<StarterPackBloc, StarterPackState>( 205 + 'StarterPackUpdated is a no-op when ref list is missing', 206 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 207 + seed: () => StarterPackState.loaded( 208 + packUri: packUri, 209 + starterPack: _buildStarterPackView(packUri: packUri, refListUri: null), 210 + ), 211 + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), 212 + expect: () => [], 213 + ); 214 + 215 + blocTest<StarterPackBloc, StarterPackState>( 216 + 'StarterPackUpdated is a no-op when a mutation is in progress', 217 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 218 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack, isMutating: true), 219 + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), 220 + expect: () => [], 221 + ); 222 + 223 + blocTest<StarterPackBloc, StarterPackState>( 224 + 'deletes the starter pack and its reference list', 225 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 226 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 227 + setUp: () { 228 + when( 229 + () => mockRepository.deleteStarterPack( 230 + packUri: packUri, 231 + referenceListUri: refListUri, 232 + userDid: 'did:plc:creator', 233 + ), 234 + ).thenAnswer((_) async {}); 235 + }, 236 + act: (bloc) => bloc.add(const StarterPackDeleted(userDid: 'did:plc:creator')), 237 + expect: () => [ 238 + predicate<StarterPackState>((state) => state.isMutating), 239 + predicate<StarterPackState>((state) => state.status == StarterPackStatus.deleted), 240 + ], 241 + verify: (_) { 242 + verify( 243 + () => mockRepository.deleteStarterPack( 244 + packUri: packUri, 245 + referenceListUri: refListUri, 246 + userDid: 'did:plc:creator', 247 + ), 248 + ).called(1); 249 + }, 250 + ); 251 + 252 + blocTest<StarterPackBloc, StarterPackState>( 253 + 'StarterPackDeleted emits error when delete fails', 254 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 255 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 256 + setUp: () { 257 + when( 258 + () => mockRepository.deleteStarterPack( 259 + packUri: any(named: 'packUri'), 260 + referenceListUri: any(named: 'referenceListUri'), 261 + userDid: any(named: 'userDid'), 262 + ), 263 + ).thenThrow(Exception('network error')); 264 + }, 265 + act: (bloc) => bloc.add(const StarterPackDeleted(userDid: 'did:plc:creator')), 266 + expect: () => [ 267 + predicate<StarterPackState>((state) => state.isMutating && state.errorMessage == null), 268 + predicate<StarterPackState>((state) => !state.isMutating && state.errorMessage != null), 269 + ], 270 + ); 271 + 272 + blocTest<StarterPackBloc, StarterPackState>( 273 + 'StarterPackDeleted is a no-op when not loaded', 274 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 275 + act: (bloc) => bloc.add(const StarterPackDeleted(userDid: 'did:plc:creator')), 276 + expect: () => [], 277 + ); 278 + 279 + blocTest<StarterPackBloc, StarterPackState>( 280 + 'adds a member and reloads', 281 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 282 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 283 + setUp: () { 284 + when( 285 + () => mockRepository.addMember(listUri: refListUri, subjectDid: 'did:plc:new-member'), 286 + ).thenAnswer((_) async => itemUri.toString()); 287 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); 288 + }, 289 + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), 290 + expect: () => [ 291 + predicate<StarterPackState>((state) => state.isMutating && !state.isRefreshing), 292 + predicate<StarterPackState>((state) => !state.isMutating && state.status == StarterPackStatus.loaded), 293 + ], 294 + verify: (_) { 295 + verify(() => mockRepository.addMember(listUri: refListUri, subjectDid: 'did:plc:new-member')).called(1); 296 + }, 297 + ); 298 + 299 + blocTest<StarterPackBloc, StarterPackState>( 300 + 'MemberAdded emits error when addMember fails', 301 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 302 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 303 + setUp: () { 304 + when( 305 + () => mockRepository.addMember( 306 + listUri: any(named: 'listUri'), 307 + subjectDid: any(named: 'subjectDid'), 308 + ), 309 + ).thenThrow(Exception('network error')); 310 + }, 311 + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), 312 + expect: () => [ 313 + predicate<StarterPackState>((state) => state.isMutating && state.errorMessage == null), 314 + predicate<StarterPackState>((state) => !state.isMutating && state.errorMessage != null), 315 + ], 316 + ); 317 + 318 + blocTest<StarterPackBloc, StarterPackState>( 319 + 'MemberAdded is a no-op when not loaded', 320 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 321 + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), 322 + expect: () => [], 323 + ); 324 + 325 + blocTest<StarterPackBloc, StarterPackState>( 326 + 'MemberAdded is a no-op when ref list is missing', 327 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 328 + seed: () => StarterPackState.loaded( 329 + packUri: packUri, 330 + starterPack: _buildStarterPackView(packUri: packUri, refListUri: null), 331 + ), 332 + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), 333 + expect: () => [], 334 + ); 335 + 336 + blocTest<StarterPackBloc, StarterPackState>( 337 + 'MemberAdded is a no-op when a mutation is in progress', 338 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 339 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack, isMutating: true), 340 + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), 341 + expect: () => [], 342 + ); 343 + 344 + blocTest<StarterPackBloc, StarterPackState>( 345 + 'removes a member and reloads', 346 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 347 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 348 + setUp: () { 349 + when(() => mockRepository.removeMember(listItemUri: itemUri)).thenAnswer((_) async {}); 350 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); 351 + }, 352 + act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), 353 + expect: () => [ 354 + predicate<StarterPackState>((state) => state.isMutating && !state.isRefreshing), 355 + predicate<StarterPackState>((state) => !state.isMutating && state.status == StarterPackStatus.loaded), 356 + ], 357 + verify: (_) { 358 + verify(() => mockRepository.removeMember(listItemUri: itemUri)).called(1); 359 + }, 360 + ); 361 + 362 + blocTest<StarterPackBloc, StarterPackState>( 363 + 'MemberRemoved emits error when removeMember fails', 364 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 365 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 366 + setUp: () { 367 + when( 368 + () => mockRepository.removeMember(listItemUri: any(named: 'listItemUri')), 369 + ).thenThrow(Exception('network error')); 370 + }, 371 + act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), 372 + expect: () => [ 373 + predicate<StarterPackState>((state) => state.isMutating && state.errorMessage == null), 374 + predicate<StarterPackState>((state) => !state.isMutating && state.errorMessage != null), 375 + ], 376 + ); 377 + 378 + blocTest<StarterPackBloc, StarterPackState>( 379 + 'MemberRemoved is a no-op when not loaded', 380 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 381 + act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), 382 + expect: () => [], 383 + ); 384 + 385 + blocTest<StarterPackBloc, StarterPackState>( 386 + 'MemberRemoved is a no-op when a mutation is in progress', 387 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 388 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack, isMutating: true), 389 + act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), 390 + expect: () => [], 391 + ); 392 + }); 393 + } 394 + 395 + StarterPackView _buildStarterPackView({required AtUri packUri, AtUri? refListUri}) { 396 + final listViewBasic = refListUri == null 397 + ? null 398 + : ListViewBasic( 399 + uri: refListUri, 400 + cid: 'cid-ref-list', 401 + name: 'Starter Pack Members', 402 + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsReferencelist), 403 + ); 404 + 405 + return StarterPackView( 406 + uri: packUri, 407 + cid: 'cid-pack', 408 + record: const { 409 + r'$type': 'app.bsky.graph.starterpack', 410 + 'name': 'My Starter Pack', 411 + 'list': 'at://did:plc:creator/app.bsky.graph.list/ref-list-1', 412 + 'createdAt': '2026-03-22T00:00:00.000Z', 413 + }, 414 + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 415 + list: listViewBasic, 416 + indexedAt: DateTime.utc(2026, 3, 22), 417 + ); 418 + }
+198
test/features/starter_packs/cubit/actor_starter_packs_cubit_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bloc_test/bloc_test.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_graph_defs.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 7 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockStarterPackRepository extends Mock implements StarterPackRepository {} 11 + 12 + void main() { 13 + late MockStarterPackRepository mockRepository; 14 + 15 + const actor = 'did:plc:creator'; 16 + 17 + final packUri1 = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'); 18 + final packUri2 = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-2'); 19 + 20 + setUp(() { 21 + mockRepository = MockStarterPackRepository(); 22 + }); 23 + 24 + group('ActorStarterPacksCubit', () { 25 + final pack1 = _buildStarterPackViewBasic(uri: packUri1, name: 'Pack One'); 26 + final pack2 = _buildStarterPackViewBasic(uri: packUri2, name: 'Pack Two'); 27 + 28 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 29 + 'loads starter packs for an actor', 30 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 31 + setUp: () { 32 + when( 33 + () => mockRepository.getActorStarterPacks( 34 + actor: actor, 35 + cursor: any(named: 'cursor'), 36 + limit: 25, 37 + ), 38 + ).thenAnswer((_) async => ActorStarterPacksResult(starterPacks: [pack1, pack2], cursor: 'cursor-1')); 39 + }, 40 + act: (cubit) => cubit.load(actor: actor, limit: 25), 41 + expect: () => [ 42 + const ActorStarterPacksState.loading(actor: actor, limit: 25), 43 + predicate<ActorStarterPacksState>( 44 + (state) => 45 + state.status == ActorStarterPacksStatus.loaded && 46 + state.starterPacks.length == 2 && 47 + state.cursor == 'cursor-1' && 48 + state.hasMore, 49 + ), 50 + ], 51 + ); 52 + 53 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 54 + 'emits error when load fails', 55 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 56 + setUp: () { 57 + when( 58 + () => mockRepository.getActorStarterPacks( 59 + actor: any(named: 'actor'), 60 + cursor: any(named: 'cursor'), 61 + limit: any(named: 'limit'), 62 + ), 63 + ).thenThrow(Exception('network error')); 64 + }, 65 + act: (cubit) => cubit.load(actor: actor), 66 + expect: () => [ 67 + const ActorStarterPacksState.loading(actor: actor), 68 + predicate<ActorStarterPacksState>( 69 + (state) => state.status == ActorStarterPacksStatus.error && state.errorMessage != null, 70 + ), 71 + ], 72 + ); 73 + 74 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 75 + 'refreshes the current starter pack collection', 76 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 77 + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: 'cursor-1', hasMore: true), 78 + setUp: () { 79 + when( 80 + () => mockRepository.getActorStarterPacks( 81 + actor: actor, 82 + cursor: any(named: 'cursor'), 83 + limit: 50, 84 + ), 85 + ).thenAnswer((_) async => ActorStarterPacksResult(starterPacks: [pack1, pack2], cursor: null)); 86 + }, 87 + act: (cubit) => cubit.refresh(), 88 + expect: () => [ 89 + predicate<ActorStarterPacksState>((state) => state.isRefreshing), 90 + predicate<ActorStarterPacksState>( 91 + (state) => !state.isRefreshing && state.starterPacks.length == 2 && !state.hasMore, 92 + ), 93 + ], 94 + ); 95 + 96 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 97 + 'refresh is a no-op when actor is null', 98 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 99 + act: (cubit) => cubit.refresh(), 100 + expect: () => [], 101 + ); 102 + 103 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 104 + 'refresh emits error when fetch fails', 105 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 106 + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: null, hasMore: false), 107 + setUp: () { 108 + when( 109 + () => mockRepository.getActorStarterPacks( 110 + actor: any(named: 'actor'), 111 + cursor: any(named: 'cursor'), 112 + limit: any(named: 'limit'), 113 + ), 114 + ).thenThrow(Exception('network error')); 115 + }, 116 + act: (cubit) => cubit.refresh(), 117 + expect: () => [ 118 + predicate<ActorStarterPacksState>((state) => state.isRefreshing), 119 + predicate<ActorStarterPacksState>((state) => !state.isRefreshing && state.errorMessage != null), 120 + ], 121 + ); 122 + 123 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 124 + 'loads more pages appending to existing list', 125 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 126 + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: 'cursor-1', hasMore: true), 127 + setUp: () { 128 + when( 129 + () => mockRepository.getActorStarterPacks(actor: actor, cursor: 'cursor-1', limit: 50), 130 + ).thenAnswer((_) async => ActorStarterPacksResult(starterPacks: [pack2], cursor: null)); 131 + }, 132 + act: (cubit) => cubit.loadMore(), 133 + expect: () => [ 134 + predicate<ActorStarterPacksState>((state) => state.isLoadingMore), 135 + predicate<ActorStarterPacksState>( 136 + (state) => !state.isLoadingMore && state.starterPacks.length == 2 && !state.hasMore, 137 + ), 138 + ], 139 + ); 140 + 141 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 142 + 'loadMore is a no-op when no cursor', 143 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 144 + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: null, hasMore: false), 145 + act: (cubit) => cubit.loadMore(), 146 + expect: () => [], 147 + ); 148 + 149 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 150 + 'loadMore is a no-op when already loading more', 151 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 152 + seed: () => ActorStarterPacksState.loaded( 153 + actor: actor, 154 + starterPacks: [pack1], 155 + cursor: 'cursor-1', 156 + hasMore: true, 157 + isLoadingMore: true, 158 + ), 159 + act: (cubit) => cubit.loadMore(), 160 + expect: () => [], 161 + ); 162 + 163 + blocTest<ActorStarterPacksCubit, ActorStarterPacksState>( 164 + 'loadMore stops pagination when fetch fails', 165 + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), 166 + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: 'cursor-1', hasMore: true), 167 + setUp: () { 168 + when( 169 + () => mockRepository.getActorStarterPacks( 170 + actor: any(named: 'actor'), 171 + cursor: any(named: 'cursor'), 172 + limit: any(named: 'limit'), 173 + ), 174 + ).thenThrow(Exception('network error')); 175 + }, 176 + act: (cubit) => cubit.loadMore(), 177 + expect: () => [ 178 + predicate<ActorStarterPacksState>((state) => state.isLoadingMore), 179 + predicate<ActorStarterPacksState>((state) => !state.isLoadingMore && !state.hasMore), 180 + ], 181 + ); 182 + }); 183 + } 184 + 185 + StarterPackViewBasic _buildStarterPackViewBasic({required AtUri uri, required String name}) { 186 + return StarterPackViewBasic( 187 + uri: uri, 188 + cid: 'cid-${uri.rkey}', 189 + record: <String, dynamic>{ 190 + r'$type': 'app.bsky.graph.starterpack', 191 + 'name': name, 192 + 'list': 'at://did:plc:creator/app.bsky.graph.list/ref', 193 + 'createdAt': '2026-03-22T00:00:00.000Z', 194 + }, 195 + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 196 + indexedAt: DateTime.utc(2026, 3, 22), 197 + ); 198 + }