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 management blocs and repo

+1716 -3
+3 -3
docs/tasks/phase-4.md
··· 40 40 41 41 ### Core 42 42 43 - - [ ] `ListBloc` — events: `ListRequested`, `ListRefreshed`, `ListItemAdded`, `ListItemRemoved`, `ListMuted`, `ListUnmuted`, `ListBlocked`, `ListUnblocked` 44 - - [ ] `MyListsCubit` — load user's lists via `getLists` 45 - - [ ] `ListFeedBloc` — paginated feed via `getListFeed`, reuse existing feed pattern 43 + - [x] `ListBloc` — events: `ListRequested`, `ListRefreshed`, `ListItemAdded`, `ListItemRemoved`, `ListMuted`, `ListUnmuted`, `ListBlocked`, `ListUnblocked` 44 + - [x] `MyListsCubit` — load user's lists via `getLists` 45 + - [x] `ListFeedBloc` — paginated feed via `getListFeed`, reuse existing feed pattern 46 46 47 47 ### List CRUD 48 48
+176
lib/features/lists/bloc/list_bloc.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 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/lists/data/list_repository.dart'; 6 + 7 + part 'list_event.dart'; 8 + part 'list_state.dart'; 9 + 10 + class ListBloc extends Bloc<ListEvent, ListState> { 11 + ListBloc({required ListRepository listRepository}) 12 + : _listRepository = listRepository, 13 + super(const ListState.initial()) { 14 + on<ListRequested>(_onListRequested); 15 + on<ListRefreshed>(_onListRefreshed); 16 + on<ListItemAdded>(_onListItemAdded); 17 + on<ListItemRemoved>(_onListItemRemoved); 18 + on<ListMuted>(_onListMuted); 19 + on<ListUnmuted>(_onListUnmuted); 20 + on<ListBlocked>(_onListBlocked); 21 + on<ListUnblocked>(_onListUnblocked); 22 + } 23 + 24 + final ListRepository _listRepository; 25 + 26 + Future<void> _onListRequested(ListRequested event, Emitter<ListState> emit) async { 27 + emit(ListState.loading(listUri: event.listUri, limit: event.limit)); 28 + 29 + try { 30 + final result = await _listRepository.getList(listUri: event.listUri, limit: event.limit); 31 + 32 + emit( 33 + ListState.loaded( 34 + listUri: event.listUri, 35 + list: result.list, 36 + items: result.items, 37 + cursor: result.cursor, 38 + hasMore: result.cursor != null, 39 + limit: event.limit, 40 + ), 41 + ); 42 + } catch (error) { 43 + emit(ListState.error(message: 'Failed to load list: $error', listUri: event.listUri, limit: event.limit)); 44 + } 45 + } 46 + 47 + Future<void> _onListRefreshed(ListRefreshed event, Emitter<ListState> emit) async { 48 + if (state.listUri == null) { 49 + return; 50 + } 51 + 52 + await _reloadList(emit, isRefreshing: true, errorPrefix: 'Failed to refresh list'); 53 + } 54 + 55 + Future<void> _onListItemAdded(ListItemAdded event, Emitter<ListState> emit) async { 56 + if (state.listUri == null || state.isMutating) { 57 + return; 58 + } 59 + 60 + await _runMutation( 61 + emit, 62 + action: () => _listRepository.addListItem(listUri: state.listUri!, subjectDid: event.subjectDid), 63 + errorPrefix: 'Failed to add member', 64 + ); 65 + } 66 + 67 + Future<void> _onListItemRemoved(ListItemRemoved event, Emitter<ListState> emit) async { 68 + if (state.listUri == null || state.isMutating) { 69 + return; 70 + } 71 + 72 + await _runMutation( 73 + emit, 74 + action: () => _listRepository.removeListItem(listItemUri: event.listItemUri), 75 + errorPrefix: 'Failed to remove member', 76 + ); 77 + } 78 + 79 + Future<void> _onListMuted(ListMuted event, Emitter<ListState> emit) async { 80 + if (state.listUri == null || state.isMutating) { 81 + return; 82 + } 83 + 84 + await _runMutation( 85 + emit, 86 + action: () => _listRepository.muteList(listUri: state.listUri!), 87 + errorPrefix: 'Failed to mute list', 88 + ); 89 + } 90 + 91 + Future<void> _onListUnmuted(ListUnmuted event, Emitter<ListState> emit) async { 92 + if (state.listUri == null || state.isMutating) { 93 + return; 94 + } 95 + 96 + await _runMutation( 97 + emit, 98 + action: () => _listRepository.unmuteList(listUri: state.listUri!), 99 + errorPrefix: 'Failed to unmute list', 100 + ); 101 + } 102 + 103 + Future<void> _onListBlocked(ListBlocked event, Emitter<ListState> emit) async { 104 + if (state.listUri == null || state.isMutating) { 105 + return; 106 + } 107 + 108 + await _runMutation( 109 + emit, 110 + action: () => _listRepository.blockList(listUri: state.listUri!), 111 + errorPrefix: 'Failed to block list', 112 + ); 113 + } 114 + 115 + Future<void> _onListUnblocked(ListUnblocked event, Emitter<ListState> emit) async { 116 + final blockUri = state.list?.viewer?.blocked; 117 + if (blockUri == null || state.isMutating) { 118 + return; 119 + } 120 + 121 + await _runMutation( 122 + emit, 123 + action: () => _listRepository.unblockList(blockUri: blockUri), 124 + errorPrefix: 'Failed to unblock list', 125 + ); 126 + } 127 + 128 + Future<void> _runMutation( 129 + Emitter<ListState> emit, { 130 + required Future<void> Function() action, 131 + required String errorPrefix, 132 + }) async { 133 + if (state.status != ListStatus.loaded || state.listUri == null) { 134 + return; 135 + } 136 + 137 + emit(state.copyWith(isMutating: true, errorMessage: null)); 138 + 139 + try { 140 + await action(); 141 + await _reloadList(emit, isRefreshing: false, isMutating: true, errorPrefix: errorPrefix); 142 + } catch (error) { 143 + emit(state.copyWith(isMutating: false, errorMessage: '$errorPrefix: $error')); 144 + } 145 + } 146 + 147 + Future<void> _reloadList( 148 + Emitter<ListState> emit, { 149 + required bool isRefreshing, 150 + required String errorPrefix, 151 + bool isMutating = false, 152 + }) async { 153 + final listUri = state.listUri; 154 + if (listUri == null) { 155 + return; 156 + } 157 + 158 + emit(state.copyWith(isRefreshing: isRefreshing, isMutating: isMutating, errorMessage: null)); 159 + 160 + try { 161 + final result = await _listRepository.getList(listUri: listUri, limit: state.limit); 162 + emit( 163 + ListState.loaded( 164 + listUri: listUri, 165 + list: result.list, 166 + items: result.items, 167 + cursor: result.cursor, 168 + hasMore: result.cursor != null, 169 + limit: state.limit, 170 + ), 171 + ); 172 + } catch (error) { 173 + emit(state.copyWith(isRefreshing: false, isMutating: false, errorMessage: '$errorPrefix: $error')); 174 + } 175 + } 176 + }
+56
lib/features/lists/bloc/list_event.dart
··· 1 + part of 'list_bloc.dart'; 2 + 3 + sealed class ListEvent extends Equatable { 4 + const ListEvent(); 5 + 6 + @override 7 + List<Object?> get props => []; 8 + } 9 + 10 + final class ListRequested extends ListEvent { 11 + const ListRequested({required this.listUri, this.limit = 50}); 12 + 13 + final AtUri listUri; 14 + final int limit; 15 + 16 + @override 17 + List<Object?> get props => [listUri, limit]; 18 + } 19 + 20 + final class ListRefreshed extends ListEvent { 21 + const ListRefreshed(); 22 + } 23 + 24 + final class ListItemAdded extends ListEvent { 25 + const ListItemAdded({required this.subjectDid}); 26 + 27 + final String subjectDid; 28 + 29 + @override 30 + List<Object?> get props => [subjectDid]; 31 + } 32 + 33 + final class ListItemRemoved extends ListEvent { 34 + const ListItemRemoved({required this.listItemUri}); 35 + 36 + final AtUri listItemUri; 37 + 38 + @override 39 + List<Object?> get props => [listItemUri]; 40 + } 41 + 42 + final class ListMuted extends ListEvent { 43 + const ListMuted(); 44 + } 45 + 46 + final class ListUnmuted extends ListEvent { 47 + const ListUnmuted(); 48 + } 49 + 50 + final class ListBlocked extends ListEvent { 51 + const ListBlocked(); 52 + } 53 + 54 + final class ListUnblocked extends ListEvent { 55 + const ListUnblocked(); 56 + }
+84
lib/features/lists/bloc/list_feed_bloc.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_feed_defs.dart'; 3 + import 'package:equatable/equatable.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/features/lists/data/list_repository.dart'; 6 + 7 + part 'list_feed_event.dart'; 8 + part 'list_feed_state.dart'; 9 + 10 + class ListFeedBloc extends Bloc<ListFeedEvent, ListFeedState> { 11 + ListFeedBloc({required ListRepository listRepository}) 12 + : _listRepository = listRepository, 13 + super(const ListFeedState.initial()) { 14 + on<ListFeedRequested>(_onListFeedRequested); 15 + on<ListFeedLoadMoreRequested>(_onListFeedLoadMoreRequested); 16 + on<ListFeedRefreshed>(_onListFeedRefreshed); 17 + } 18 + 19 + final ListRepository _listRepository; 20 + 21 + Future<void> _onListFeedRequested(ListFeedRequested event, Emitter<ListFeedState> emit) async { 22 + emit(ListFeedState.loading(listUri: event.listUri)); 23 + 24 + try { 25 + final result = await _listRepository.getListFeed(listUri: event.listUri, limit: event.limit); 26 + 27 + emit( 28 + ListFeedState.loaded( 29 + listUri: event.listUri, 30 + posts: result.posts, 31 + cursor: result.cursor, 32 + hasMore: result.cursor != null, 33 + ), 34 + ); 35 + } catch (error) { 36 + emit(ListFeedState.error('Failed to load list feed: $error', listUri: event.listUri)); 37 + } 38 + } 39 + 40 + Future<void> _onListFeedLoadMoreRequested(ListFeedLoadMoreRequested event, Emitter<ListFeedState> emit) async { 41 + if (state.status != ListFeedStatus.loaded || state.listUri == null || state.cursor == null || state.isLoadingMore) { 42 + return; 43 + } 44 + 45 + emit(state.copyWith(isLoadingMore: true)); 46 + 47 + try { 48 + final result = await _listRepository.getListFeed( 49 + listUri: state.listUri!, 50 + cursor: state.cursor, 51 + limit: event.limit, 52 + ); 53 + 54 + emit( 55 + state.copyWith( 56 + posts: [...state.posts, ...result.posts], 57 + cursor: result.cursor, 58 + hasMore: result.cursor != null, 59 + isLoadingMore: false, 60 + ), 61 + ); 62 + } catch (_) { 63 + emit(state.copyWith(isLoadingMore: false, hasMore: false)); 64 + } 65 + } 66 + 67 + Future<void> _onListFeedRefreshed(ListFeedRefreshed event, Emitter<ListFeedState> emit) async { 68 + if (state.status != ListFeedStatus.loaded || state.listUri == null) { 69 + return; 70 + } 71 + 72 + emit(state.copyWith(isRefreshing: true)); 73 + 74 + try { 75 + final result = await _listRepository.getListFeed(listUri: state.listUri!, limit: event.limit); 76 + 77 + emit( 78 + state.copyWith(posts: result.posts, cursor: result.cursor, hasMore: result.cursor != null, isRefreshing: false), 79 + ); 80 + } catch (_) { 81 + emit(state.copyWith(isRefreshing: false)); 82 + } 83 + } 84 + }
+36
lib/features/lists/bloc/list_feed_event.dart
··· 1 + part of 'list_feed_bloc.dart'; 2 + 3 + sealed class ListFeedEvent extends Equatable { 4 + const ListFeedEvent(); 5 + 6 + @override 7 + List<Object?> get props => []; 8 + } 9 + 10 + final class ListFeedRequested extends ListFeedEvent { 11 + const ListFeedRequested({required this.listUri, this.limit = 50}); 12 + 13 + final AtUri listUri; 14 + final int limit; 15 + 16 + @override 17 + List<Object?> get props => [listUri, limit]; 18 + } 19 + 20 + final class ListFeedLoadMoreRequested extends ListFeedEvent { 21 + const ListFeedLoadMoreRequested({this.limit = 50}); 22 + 23 + final int limit; 24 + 25 + @override 26 + List<Object?> get props => [limit]; 27 + } 28 + 29 + final class ListFeedRefreshed extends ListFeedEvent { 30 + const ListFeedRefreshed({this.limit = 50}); 31 + 32 + final int limit; 33 + 34 + @override 35 + List<Object?> get props => [limit]; 36 + }
+82
lib/features/lists/bloc/list_feed_state.dart
··· 1 + part of 'list_feed_bloc.dart'; 2 + 3 + enum ListFeedStatus { initial, loading, loaded, error } 4 + 5 + class ListFeedState extends Equatable { 6 + const ListFeedState._({ 7 + required this.status, 8 + this.listUri, 9 + this.posts = const [], 10 + this.cursor, 11 + this.hasMore = false, 12 + this.isRefreshing = false, 13 + this.isLoadingMore = false, 14 + this.errorMessage, 15 + }); 16 + 17 + const ListFeedState.initial() : this._(status: ListFeedStatus.initial); 18 + 19 + const ListFeedState.loading({required AtUri listUri}) : this._(status: ListFeedStatus.loading, listUri: listUri); 20 + 21 + const ListFeedState.loaded({ 22 + required AtUri listUri, 23 + required List<FeedViewPost> posts, 24 + String? cursor, 25 + required bool hasMore, 26 + bool isRefreshing = false, 27 + bool isLoadingMore = false, 28 + String? errorMessage, 29 + }) : this._( 30 + status: ListFeedStatus.loaded, 31 + listUri: listUri, 32 + posts: posts, 33 + cursor: cursor, 34 + hasMore: hasMore, 35 + isRefreshing: isRefreshing, 36 + isLoadingMore: isLoadingMore, 37 + errorMessage: errorMessage, 38 + ); 39 + 40 + const ListFeedState.error(String message, {AtUri? listUri}) 41 + : this._(status: ListFeedStatus.error, listUri: listUri, errorMessage: message); 42 + 43 + final ListFeedStatus status; 44 + final AtUri? listUri; 45 + final List<FeedViewPost> posts; 46 + final String? cursor; 47 + final bool hasMore; 48 + final bool isRefreshing; 49 + final bool isLoadingMore; 50 + final String? errorMessage; 51 + 52 + bool get isLoading => status == ListFeedStatus.loading; 53 + bool get hasError => status == ListFeedStatus.error; 54 + bool get hasPosts => posts.isNotEmpty; 55 + 56 + ListFeedState copyWith({ 57 + ListFeedStatus? status, 58 + Object? listUri = _listFeedNoValue, 59 + List<FeedViewPost>? posts, 60 + Object? cursor = _listFeedNoValue, 61 + bool? hasMore, 62 + bool? isRefreshing, 63 + bool? isLoadingMore, 64 + Object? errorMessage = _listFeedNoValue, 65 + }) { 66 + return ListFeedState._( 67 + status: status ?? this.status, 68 + listUri: listUri == _listFeedNoValue ? this.listUri : listUri as AtUri?, 69 + posts: posts ?? this.posts, 70 + cursor: cursor == _listFeedNoValue ? this.cursor : cursor as String?, 71 + hasMore: hasMore ?? this.hasMore, 72 + isRefreshing: isRefreshing ?? this.isRefreshing, 73 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 74 + errorMessage: errorMessage == _listFeedNoValue ? this.errorMessage : errorMessage as String?, 75 + ); 76 + } 77 + 78 + @override 79 + List<Object?> get props => [status, listUri, posts, cursor, hasMore, isRefreshing, isLoadingMore, errorMessage]; 80 + } 81 + 82 + const _listFeedNoValue = Object();
+106
lib/features/lists/bloc/list_state.dart
··· 1 + part of 'list_bloc.dart'; 2 + 3 + enum ListStatus { initial, loading, loaded, error } 4 + 5 + class ListState extends Equatable { 6 + const ListState._({ 7 + required this.status, 8 + this.listUri, 9 + this.list, 10 + this.items = const [], 11 + this.cursor, 12 + this.hasMore = false, 13 + this.limit = 50, 14 + this.isRefreshing = false, 15 + this.isMutating = false, 16 + this.errorMessage, 17 + }); 18 + 19 + const ListState.initial() : this._(status: ListStatus.initial); 20 + 21 + const ListState.loading({required AtUri listUri, int limit = 50}) 22 + : this._(status: ListStatus.loading, listUri: listUri, limit: limit); 23 + 24 + const ListState.loaded({ 25 + required AtUri listUri, 26 + required ListView list, 27 + required List<ListItemView> items, 28 + String? cursor, 29 + required bool hasMore, 30 + int limit = 50, 31 + bool isRefreshing = false, 32 + bool isMutating = false, 33 + String? errorMessage, 34 + }) : this._( 35 + status: ListStatus.loaded, 36 + listUri: listUri, 37 + list: list, 38 + items: items, 39 + cursor: cursor, 40 + hasMore: hasMore, 41 + limit: limit, 42 + isRefreshing: isRefreshing, 43 + isMutating: isMutating, 44 + errorMessage: errorMessage, 45 + ); 46 + 47 + const ListState.error({required String message, AtUri? listUri, int limit = 50}) 48 + : this._(status: ListStatus.error, listUri: listUri, limit: limit, errorMessage: message); 49 + 50 + final ListStatus status; 51 + final AtUri? listUri; 52 + final ListView? list; 53 + final List<ListItemView> items; 54 + final String? cursor; 55 + final bool hasMore; 56 + final int limit; 57 + final bool isRefreshing; 58 + final bool isMutating; 59 + final String? errorMessage; 60 + 61 + bool get isLoading => status == ListStatus.loading; 62 + bool get hasError => status == ListStatus.error || errorMessage != null; 63 + bool get hasItems => items.isNotEmpty; 64 + 65 + ListState copyWith({ 66 + ListStatus? status, 67 + Object? listUri = _listNoValue, 68 + Object? list = _listNoValue, 69 + List<ListItemView>? items, 70 + Object? cursor = _listNoValue, 71 + bool? hasMore, 72 + int? limit, 73 + bool? isRefreshing, 74 + bool? isMutating, 75 + Object? errorMessage = _listNoValue, 76 + }) { 77 + return ListState._( 78 + status: status ?? this.status, 79 + listUri: listUri == _listNoValue ? this.listUri : listUri as AtUri?, 80 + list: list == _listNoValue ? this.list : list as ListView?, 81 + items: items ?? this.items, 82 + cursor: cursor == _listNoValue ? this.cursor : cursor as String?, 83 + hasMore: hasMore ?? this.hasMore, 84 + limit: limit ?? this.limit, 85 + isRefreshing: isRefreshing ?? this.isRefreshing, 86 + isMutating: isMutating ?? this.isMutating, 87 + errorMessage: errorMessage == _listNoValue ? this.errorMessage : errorMessage as String?, 88 + ); 89 + } 90 + 91 + @override 92 + List<Object?> get props => [ 93 + status, 94 + listUri, 95 + list, 96 + items, 97 + cursor, 98 + hasMore, 99 + limit, 100 + isRefreshing, 101 + isMutating, 102 + errorMessage, 103 + ]; 104 + } 105 + 106 + const _listNoValue = Object();
+79
lib/features/lists/cubit/my_lists_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/lists/data/list_repository.dart'; 5 + 6 + part 'my_lists_state.dart'; 7 + 8 + class MyListsCubit extends Cubit<MyListsState> { 9 + MyListsCubit({required ListRepository listRepository}) 10 + : _listRepository = listRepository, 11 + super(const MyListsState.initial()); 12 + 13 + final ListRepository _listRepository; 14 + 15 + Future<void> load({required String actor, int limit = 50}) async { 16 + emit(MyListsState.loading(actor: actor, limit: limit)); 17 + 18 + try { 19 + final result = await _listRepository.getLists(actor: actor, limit: limit); 20 + emit( 21 + MyListsState.loaded( 22 + actor: actor, 23 + lists: result.lists, 24 + cursor: result.cursor, 25 + hasMore: result.cursor != null, 26 + limit: limit, 27 + ), 28 + ); 29 + } catch (error) { 30 + emit(MyListsState.error(message: 'Failed to load lists: $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 _listRepository.getLists(actor: state.actor!, limit: state.limit); 43 + emit( 44 + MyListsState.loaded( 45 + actor: state.actor!, 46 + lists: result.lists, 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 lists: $error')); 54 + } 55 + } 56 + 57 + Future<void> loadMore() async { 58 + if (state.status != MyListsStatus.loaded || state.actor == null || state.cursor == null || state.isLoadingMore) { 59 + return; 60 + } 61 + 62 + emit(state.copyWith(isLoadingMore: true)); 63 + 64 + try { 65 + final result = await _listRepository.getLists(actor: state.actor!, cursor: state.cursor, limit: state.limit); 66 + 67 + emit( 68 + state.copyWith( 69 + lists: [...state.lists, ...result.lists], 70 + cursor: result.cursor, 71 + hasMore: result.cursor != null, 72 + isLoadingMore: false, 73 + ), 74 + ); 75 + } catch (_) { 76 + emit(state.copyWith(isLoadingMore: false, hasMore: false)); 77 + } 78 + } 79 + }
+101
lib/features/lists/cubit/my_lists_state.dart
··· 1 + part of 'my_lists_cubit.dart'; 2 + 3 + enum MyListsStatus { initial, loading, loaded, error } 4 + 5 + class MyListsState extends Equatable { 6 + const MyListsState._({ 7 + required this.status, 8 + this.actor, 9 + this.lists = 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 MyListsState.initial() : this._(status: MyListsStatus.initial); 19 + 20 + const MyListsState.loading({required String actor, int limit = 50}) 21 + : this._(status: MyListsStatus.loading, actor: actor, limit: limit); 22 + 23 + const MyListsState.loaded({ 24 + required String actor, 25 + required List<ListView> lists, 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: MyListsStatus.loaded, 34 + actor: actor, 35 + lists: lists, 36 + cursor: cursor, 37 + hasMore: hasMore, 38 + limit: limit, 39 + isRefreshing: isRefreshing, 40 + isLoadingMore: isLoadingMore, 41 + errorMessage: errorMessage, 42 + ); 43 + 44 + const MyListsState.error({required String message, String? actor, int limit = 50}) 45 + : this._(status: MyListsStatus.error, actor: actor, limit: limit, errorMessage: message); 46 + 47 + final MyListsStatus status; 48 + final String? actor; 49 + final List<ListView> lists; 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 + List<ListView> get curationLists => lists.where(_isCurationList).toList(growable: false); 58 + List<ListView> get moderationLists => lists.where(_isModerationList).toList(growable: false); 59 + List<ListView> get referenceLists => lists.where(_isReferenceList).toList(growable: false); 60 + 61 + MyListsState copyWith({ 62 + MyListsStatus? status, 63 + Object? actor = _myListsNoValue, 64 + List<ListView>? lists, 65 + Object? cursor = _myListsNoValue, 66 + bool? hasMore, 67 + int? limit, 68 + bool? isRefreshing, 69 + bool? isLoadingMore, 70 + Object? errorMessage = _myListsNoValue, 71 + }) { 72 + return MyListsState._( 73 + status: status ?? this.status, 74 + actor: actor == _myListsNoValue ? this.actor : actor as String?, 75 + lists: lists ?? this.lists, 76 + cursor: cursor == _myListsNoValue ? this.cursor : cursor as String?, 77 + hasMore: hasMore ?? this.hasMore, 78 + limit: limit ?? this.limit, 79 + isRefreshing: isRefreshing ?? this.isRefreshing, 80 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 81 + errorMessage: errorMessage == _myListsNoValue ? this.errorMessage : errorMessage as String?, 82 + ); 83 + } 84 + 85 + @override 86 + List<Object?> get props => [status, actor, lists, cursor, hasMore, limit, isRefreshing, isLoadingMore, errorMessage]; 87 + } 88 + 89 + bool _isCurationList(ListView list) { 90 + return list.purpose.knownValue == KnownListPurpose.appBskyGraphDefsCuratelist; 91 + } 92 + 93 + bool _isModerationList(ListView list) { 94 + return list.purpose.knownValue == KnownListPurpose.appBskyGraphDefsModlist; 95 + } 96 + 97 + bool _isReferenceList(ListView list) { 98 + return list.purpose.knownValue == KnownListPurpose.appBskyGraphDefsReferencelist; 99 + } 100 + 101 + const _myListsNoValue = Object();
+201
lib/features/lists/data/list_repository.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bluesky/app_bsky_graph_defs.dart'; 5 + import 'package:bluesky/app_bsky_graph_getlists.dart'; 6 + import 'package:bluesky/app_bsky_graph_getlistswithmembership.dart'; 7 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 8 + 9 + class ListRepository { 10 + ListRepository({required dynamic bluesky, ModerationService? moderationService}) 11 + : _bluesky = bluesky, 12 + _moderationService = moderationService; 13 + 14 + final dynamic _bluesky; 15 + final ModerationService? _moderationService; 16 + 17 + Future<ListsResult> getLists({ 18 + required String actor, 19 + String? cursor, 20 + int limit = 50, 21 + bool includeReference = false, 22 + }) async { 23 + final response = await _bluesky.graph.getLists( 24 + actor: actor, 25 + cursor: cursor, 26 + limit: limit, 27 + purposes: includeReference ? null : _listPurposes, 28 + $headers: await _moderationService?.headersForRequest(), 29 + ); 30 + 31 + return ListsResult(lists: _filterLists(response.data.lists), cursor: response.data.cursor); 32 + } 33 + 34 + Future<ListDetailResult> getList({required AtUri listUri, String? cursor, int limit = 50}) async { 35 + final response = await _bluesky.graph.getList( 36 + list: listUri, 37 + cursor: cursor, 38 + limit: limit, 39 + $headers: await _moderationService?.headersForRequest(), 40 + ); 41 + 42 + return ListDetailResult( 43 + list: response.data.list, 44 + items: _filterListItems(response.data.items), 45 + cursor: response.data.cursor, 46 + ); 47 + } 48 + 49 + Future<ListFeedResult> getListFeed({required AtUri listUri, String? cursor, int limit = 50}) async { 50 + final response = await _bluesky.feed.getListFeed( 51 + list: listUri, 52 + cursor: cursor, 53 + limit: limit, 54 + $headers: await _moderationService?.headersForRequest(), 55 + ); 56 + 57 + return ListFeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 58 + } 59 + 60 + Future<ListsWithMembershipResult> getListsWithMembership({ 61 + required String actor, 62 + String? cursor, 63 + int limit = 50, 64 + }) async { 65 + final response = await _bluesky.graph.getListsWithMembership( 66 + actor: actor, 67 + cursor: cursor, 68 + limit: limit, 69 + purposes: _membershipPurposes, 70 + $headers: await _moderationService?.headersForRequest(), 71 + ); 72 + 73 + return ListsWithMembershipResult( 74 + lists: response.data.listsWithMembership.where((entry) => !_shouldFilterList(entry.list)).toList(growable: false), 75 + cursor: response.data.cursor, 76 + ); 77 + } 78 + 79 + Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 10}) async { 80 + final response = await _bluesky.actor.searchActorsTypeahead( 81 + q: query, 82 + limit: limit, 83 + $headers: await _moderationService?.headersForRequest(), 84 + ); 85 + 86 + return _filterProfiles(response.data.actors); 87 + } 88 + 89 + Future<String> addListItem({required AtUri listUri, required String subjectDid}) async { 90 + final response = await _bluesky.graph.listitem.create( 91 + list: listUri, 92 + subject: subjectDid, 93 + createdAt: DateTime.now(), 94 + ); 95 + 96 + return response.data.uri.toString(); 97 + } 98 + 99 + Future<void> removeListItem({required AtUri listItemUri}) async { 100 + await _bluesky.graph.listitem.delete(rkey: listItemUri.rkey); 101 + } 102 + 103 + Future<void> muteList({required AtUri listUri}) async { 104 + await _bluesky.graph.muteActorList(list: listUri); 105 + } 106 + 107 + Future<void> unmuteList({required AtUri listUri}) async { 108 + await _bluesky.graph.unmuteActorList(list: listUri); 109 + } 110 + 111 + Future<String> blockList({required AtUri listUri}) async { 112 + final response = await _bluesky.graph.listblock.create(subject: listUri, createdAt: DateTime.now()); 113 + 114 + return response.data.uri.toString(); 115 + } 116 + 117 + Future<void> unblockList({required AtUri blockUri}) async { 118 + await _bluesky.graph.listblock.delete(rkey: blockUri.rkey); 119 + } 120 + 121 + List<ListView> _filterLists(List<ListView> lists) { 122 + return lists.where((list) => !_shouldFilterList(list)).toList(growable: false); 123 + } 124 + 125 + List<ListItemView> _filterListItems(List<ListItemView> items) { 126 + final moderationService = _moderationService; 127 + if (moderationService == null) { 128 + return items; 129 + } 130 + 131 + return items.where((item) => !moderationService.shouldFilterProfileInList(item.subject)).toList(growable: false); 132 + } 133 + 134 + List<ProfileViewBasic> _filterProfiles(List<ProfileViewBasic> profiles) { 135 + final moderationService = _moderationService; 136 + if (moderationService == null) { 137 + return profiles; 138 + } 139 + 140 + return profiles 141 + .where((profile) => !moderationService.shouldFilterProfileBasicInList(profile)) 142 + .toList(growable: false); 143 + } 144 + 145 + List<FeedViewPost> _filterFeedPosts(List<FeedViewPost> posts) { 146 + final moderationService = _moderationService; 147 + if (moderationService == null) { 148 + return posts; 149 + } 150 + 151 + return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(growable: false); 152 + } 153 + 154 + bool _shouldFilterList(ListView list) { 155 + final moderationService = _moderationService; 156 + if (moderationService == null) { 157 + return false; 158 + } 159 + 160 + return moderationService.shouldFilterProfileInList(list.creator); 161 + } 162 + 163 + static const List<GraphGetListsPurposes> _listPurposes = [ 164 + GraphGetListsPurposes.knownValue(data: KnownGraphGetListsPurposes.curatelist), 165 + GraphGetListsPurposes.knownValue(data: KnownGraphGetListsPurposes.modlist), 166 + ]; 167 + 168 + static const List<GraphGetListsWithMembershipPurposes> _membershipPurposes = [ 169 + GraphGetListsWithMembershipPurposes.knownValue(data: KnownGraphGetListsWithMembershipPurposes.curatelist), 170 + GraphGetListsWithMembershipPurposes.knownValue(data: KnownGraphGetListsWithMembershipPurposes.modlist), 171 + ]; 172 + } 173 + 174 + class ListsResult { 175 + const ListsResult({required this.lists, this.cursor}); 176 + 177 + final List<ListView> lists; 178 + final String? cursor; 179 + } 180 + 181 + class ListDetailResult { 182 + const ListDetailResult({required this.list, required this.items, this.cursor}); 183 + 184 + final ListView list; 185 + final List<ListItemView> items; 186 + final String? cursor; 187 + } 188 + 189 + class ListFeedResult { 190 + const ListFeedResult({required this.posts, this.cursor}); 191 + 192 + final List<FeedViewPost> posts; 193 + final String? cursor; 194 + } 195 + 196 + class ListsWithMembershipResult { 197 + const ListsWithMembershipResult({required this.lists, this.cursor}); 198 + 199 + final List<ListWithMembership> lists; 200 + final String? cursor; 201 + }
+5
lib/main.dart
··· 23 23 import 'package:lazurite/features/feed/data/feed_repository.dart'; 24 24 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 25 25 import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 26 + import 'package:lazurite/features/lists/data/list_repository.dart'; 26 27 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 27 28 import 'package:lazurite/features/messages/data/convo_repository.dart'; 28 29 import 'package:lazurite/features/moderation/data/moderation_service.dart'; ··· 156 157 RepositoryProvider( 157 158 create: (context) => 158 159 SearchRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 160 + ), 161 + RepositoryProvider( 162 + create: (context) => 163 + ListRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 159 164 ), 160 165 RepositoryProvider( 161 166 create: (context) => ProfileRepository(
+234
test/features/lists/bloc/list_bloc_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 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/lists/bloc/list_bloc.dart'; 7 + import 'package:lazurite/features/lists/data/list_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockListRepository extends Mock implements ListRepository {} 11 + 12 + void main() { 13 + late MockListRepository mockListRepository; 14 + 15 + final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); 16 + final blockUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-1'); 17 + final firstItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); 18 + final secondItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-2'); 19 + 20 + setUpAll(() { 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 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listblock/fallback')); 24 + }); 25 + 26 + setUp(() { 27 + mockListRepository = MockListRepository(); 28 + }); 29 + 30 + group('ListBloc', () { 31 + final initialList = _buildListView(listUri: listUri); 32 + final blockedList = _buildListView(listUri: listUri, blockUri: blockUri); 33 + final firstItem = _buildListItem(uri: firstItemUri, did: 'did:plc:member-1', handle: 'member1.bsky.social'); 34 + final secondItem = _buildListItem(uri: secondItemUri, did: 'did:plc:member-2', handle: 'member2.bsky.social'); 35 + 36 + blocTest<ListBloc, ListState>( 37 + 'loads a list and its members', 38 + build: () => ListBloc(listRepository: mockListRepository), 39 + setUp: () { 40 + when( 41 + () => mockListRepository.getList( 42 + listUri: listUri, 43 + cursor: any(named: 'cursor'), 44 + limit: 25, 45 + ), 46 + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: 'cursor-1')); 47 + }, 48 + act: (bloc) => bloc.add(ListRequested(listUri: listUri, limit: 25)), 49 + expect: () => [ 50 + ListState.loading(listUri: listUri, limit: 25), 51 + ListState.loaded( 52 + listUri: listUri, 53 + list: initialList, 54 + items: [firstItem], 55 + cursor: 'cursor-1', 56 + hasMore: true, 57 + limit: 25, 58 + ), 59 + ], 60 + ); 61 + 62 + blocTest<ListBloc, ListState>( 63 + 'refreshes the active list', 64 + build: () => ListBloc(listRepository: mockListRepository), 65 + seed: () => 66 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: 'cursor-1', hasMore: true), 67 + setUp: () { 68 + when( 69 + () => mockListRepository.getList( 70 + listUri: listUri, 71 + cursor: any(named: 'cursor'), 72 + limit: 50, 73 + ), 74 + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem, secondItem], cursor: null)); 75 + }, 76 + act: (bloc) => bloc.add(const ListRefreshed()), 77 + expect: () => [ 78 + predicate<ListState>((state) => state.isRefreshing && !state.isMutating), 79 + predicate<ListState>((state) => state.items.length == 2 && !state.isRefreshing && !state.hasMore), 80 + ], 81 + ); 82 + 83 + blocTest<ListBloc, ListState>( 84 + 'adds a member and rehydrates the list', 85 + build: () => ListBloc(listRepository: mockListRepository), 86 + seed: () => 87 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 88 + setUp: () { 89 + when( 90 + () => mockListRepository.addListItem(listUri: listUri, subjectDid: 'did:plc:member-2'), 91 + ).thenAnswer((_) async => secondItemUri.toString()); 92 + when( 93 + () => mockListRepository.getList( 94 + listUri: listUri, 95 + cursor: any(named: 'cursor'), 96 + limit: 50, 97 + ), 98 + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem, secondItem], cursor: null)); 99 + }, 100 + act: (bloc) => bloc.add(const ListItemAdded(subjectDid: 'did:plc:member-2')), 101 + expect: () => [ 102 + predicate<ListState>((state) => state.isMutating && !state.isRefreshing), 103 + predicate<ListState>((state) => !state.isMutating && state.items.length == 2), 104 + ], 105 + verify: (_) { 106 + verify(() => mockListRepository.addListItem(listUri: listUri, subjectDid: 'did:plc:member-2')).called(1); 107 + }, 108 + ); 109 + 110 + blocTest<ListBloc, ListState>( 111 + 'removes a member and rehydrates the list', 112 + build: () => ListBloc(listRepository: mockListRepository), 113 + seed: () => ListState.loaded( 114 + listUri: listUri, 115 + list: initialList, 116 + items: [firstItem, secondItem], 117 + cursor: null, 118 + hasMore: false, 119 + ), 120 + setUp: () { 121 + when(() => mockListRepository.removeListItem(listItemUri: secondItemUri)).thenAnswer((_) async {}); 122 + when( 123 + () => mockListRepository.getList( 124 + listUri: listUri, 125 + cursor: any(named: 'cursor'), 126 + limit: 50, 127 + ), 128 + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: null)); 129 + }, 130 + act: (bloc) => bloc.add(ListItemRemoved(listItemUri: secondItemUri)), 131 + expect: () => [ 132 + predicate<ListState>((state) => state.isMutating), 133 + predicate<ListState>((state) => !state.isMutating && state.items.length == 1), 134 + ], 135 + ); 136 + 137 + blocTest<ListBloc, ListState>( 138 + 'mutes and unmutes the active list', 139 + build: () => ListBloc(listRepository: mockListRepository), 140 + seed: () => 141 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 142 + setUp: () { 143 + when(() => mockListRepository.muteList(listUri: listUri)).thenAnswer((_) async {}); 144 + when(() => mockListRepository.unmuteList(listUri: listUri)).thenAnswer((_) async {}); 145 + when( 146 + () => mockListRepository.getList( 147 + listUri: listUri, 148 + cursor: any(named: 'cursor'), 149 + limit: 50, 150 + ), 151 + ).thenAnswer( 152 + (_) async => ListDetailResult( 153 + list: _buildListView(listUri: listUri, isMuted: true), 154 + items: [firstItem], 155 + cursor: null, 156 + ), 157 + ); 158 + }, 159 + act: (bloc) async { 160 + bloc.add(const ListMuted()); 161 + await Future<void>.delayed(Duration.zero); 162 + when( 163 + () => mockListRepository.getList( 164 + listUri: listUri, 165 + cursor: any(named: 'cursor'), 166 + limit: 50, 167 + ), 168 + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: null)); 169 + bloc.add(const ListUnmuted()); 170 + }, 171 + expect: () => [ 172 + predicate<ListState>((state) => state.isMutating), 173 + predicate<ListState>((state) => state.list?.viewer?.isMuted ?? false), 174 + predicate<ListState>((state) => state.isMutating), 175 + predicate<ListState>((state) => !(state.list?.viewer?.isMuted ?? false)), 176 + ], 177 + ); 178 + 179 + blocTest<ListBloc, ListState>( 180 + 'blocks and unblocks the active list', 181 + build: () => ListBloc(listRepository: mockListRepository), 182 + seed: () => 183 + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), 184 + setUp: () { 185 + when(() => mockListRepository.blockList(listUri: listUri)).thenAnswer((_) async => blockUri.toString()); 186 + when(() => mockListRepository.unblockList(blockUri: blockUri)).thenAnswer((_) async {}); 187 + when( 188 + () => mockListRepository.getList( 189 + listUri: listUri, 190 + cursor: any(named: 'cursor'), 191 + limit: 50, 192 + ), 193 + ).thenAnswer((_) async => ListDetailResult(list: blockedList, items: [firstItem], cursor: null)); 194 + }, 195 + act: (bloc) async { 196 + bloc.add(const ListBlocked()); 197 + await Future<void>.delayed(Duration.zero); 198 + when( 199 + () => mockListRepository.getList( 200 + listUri: listUri, 201 + cursor: any(named: 'cursor'), 202 + limit: 50, 203 + ), 204 + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: null)); 205 + bloc.add(const ListUnblocked()); 206 + }, 207 + expect: () => [ 208 + predicate<ListState>((state) => state.isMutating), 209 + predicate<ListState>((state) => state.list?.viewer?.blocked == blockUri), 210 + predicate<ListState>((state) => state.isMutating), 211 + predicate<ListState>((state) => state.list?.viewer?.blocked == null), 212 + ], 213 + ); 214 + }); 215 + } 216 + 217 + ListView _buildListView({required AtUri listUri, bool isMuted = false, AtUri? blockUri}) { 218 + return ListView( 219 + uri: listUri, 220 + cid: 'cid-list', 221 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 222 + name: 'Core List', 223 + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsCuratelist), 224 + viewer: ListViewerState(muted: isMuted, blocked: blockUri), 225 + indexedAt: DateTime.utc(2026, 3, 21), 226 + ); 227 + } 228 + 229 + ListItemView _buildListItem({required AtUri uri, required String did, required String handle}) { 230 + return ListItemView( 231 + uri: uri, 232 + subject: ProfileView(did: did, handle: handle), 233 + ); 234 + }
+96
test/features/lists/bloc/list_feed_bloc_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bloc_test/bloc_test.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/lists/bloc/list_feed_bloc.dart'; 7 + import 'package:lazurite/features/lists/data/list_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockListRepository extends Mock implements ListRepository {} 11 + 12 + void main() { 13 + late MockListRepository mockListRepository; 14 + 15 + final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); 16 + 17 + setUpAll(() { 18 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.list/fallback')); 19 + }); 20 + 21 + setUp(() { 22 + mockListRepository = MockListRepository(); 23 + }); 24 + 25 + group('ListFeedBloc', () { 26 + final firstPost = _buildPost('at://did:plc:member-1/app.bsky.feed.post/post-1', 'First post'); 27 + final secondPost = _buildPost('at://did:plc:member-2/app.bsky.feed.post/post-2', 'Second post'); 28 + 29 + blocTest<ListFeedBloc, ListFeedState>( 30 + 'loads the list feed', 31 + build: () => ListFeedBloc(listRepository: mockListRepository), 32 + setUp: () { 33 + when( 34 + () => mockListRepository.getListFeed( 35 + listUri: listUri, 36 + cursor: any(named: 'cursor'), 37 + limit: 25, 38 + ), 39 + ).thenAnswer((_) async => ListFeedResult(posts: [firstPost], cursor: 'cursor-1')); 40 + }, 41 + act: (bloc) => bloc.add(ListFeedRequested(listUri: listUri, limit: 25)), 42 + expect: () => [ 43 + ListFeedState.loading(listUri: listUri), 44 + ListFeedState.loaded(listUri: listUri, posts: [firstPost], cursor: 'cursor-1', hasMore: true), 45 + ], 46 + ); 47 + 48 + blocTest<ListFeedBloc, ListFeedState>( 49 + 'loads more feed items using the tracked list uri', 50 + build: () => ListFeedBloc(listRepository: mockListRepository), 51 + seed: () => ListFeedState.loaded(listUri: listUri, posts: [firstPost], cursor: 'cursor-1', hasMore: true), 52 + setUp: () { 53 + when( 54 + () => mockListRepository.getListFeed(listUri: listUri, cursor: 'cursor-1', limit: 50), 55 + ).thenAnswer((_) async => ListFeedResult(posts: [secondPost], cursor: null)); 56 + }, 57 + act: (bloc) => bloc.add(const ListFeedLoadMoreRequested()), 58 + expect: () => [ 59 + predicate<ListFeedState>((state) => state.isLoadingMore), 60 + predicate<ListFeedState>((state) => !state.isLoadingMore && state.posts.length == 2 && !state.hasMore), 61 + ], 62 + ); 63 + 64 + blocTest<ListFeedBloc, ListFeedState>( 65 + 'refreshes the current list feed', 66 + build: () => ListFeedBloc(listRepository: mockListRepository), 67 + seed: () => ListFeedState.loaded(listUri: listUri, posts: [firstPost], cursor: 'cursor-1', hasMore: true), 68 + setUp: () { 69 + when( 70 + () => mockListRepository.getListFeed( 71 + listUri: listUri, 72 + cursor: any(named: 'cursor'), 73 + limit: 10, 74 + ), 75 + ).thenAnswer((_) async => ListFeedResult(posts: [secondPost], cursor: null)); 76 + }, 77 + act: (bloc) => bloc.add(const ListFeedRefreshed(limit: 10)), 78 + expect: () => [ 79 + predicate<ListFeedState>((state) => state.isRefreshing), 80 + predicate<ListFeedState>((state) => !state.isRefreshing && state.posts.single == secondPost), 81 + ], 82 + ); 83 + }); 84 + } 85 + 86 + FeedViewPost _buildPost(String uri, String text) { 87 + return FeedViewPost( 88 + post: PostView( 89 + uri: AtUri.parse(uri), 90 + cid: 'cid-${uri.hashCode}', 91 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 92 + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': DateTime.utc(2026, 3, 21).toIso8601String()}, 93 + indexedAt: DateTime.utc(2026, 3, 21), 94 + ), 95 + ); 96 + }
+123
test/features/lists/cubit/my_lists_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:atproto_core/atproto_core.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/lists/cubit/my_lists_cubit.dart'; 7 + import 'package:lazurite/features/lists/data/list_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockListRepository extends Mock implements ListRepository {} 11 + 12 + void main() { 13 + late MockListRepository mockListRepository; 14 + 15 + const actor = 'did:plc:creator'; 16 + final curationListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/curation'); 17 + final moderationListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/moderation'); 18 + final referenceListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/reference'); 19 + 20 + setUp(() { 21 + mockListRepository = MockListRepository(); 22 + }); 23 + 24 + group('MyListsCubit', () { 25 + final curationList = _buildList( 26 + uri: curationListUri, 27 + purpose: KnownListPurpose.appBskyGraphDefsCuratelist, 28 + name: 'Curation', 29 + ); 30 + final moderationList = _buildList( 31 + uri: moderationListUri, 32 + purpose: KnownListPurpose.appBskyGraphDefsModlist, 33 + name: 'Moderation', 34 + ); 35 + final referenceList = _buildList( 36 + uri: referenceListUri, 37 + purpose: KnownListPurpose.appBskyGraphDefsReferencelist, 38 + name: 'Reference', 39 + ); 40 + 41 + blocTest<MyListsCubit, MyListsState>( 42 + 'loads and categorises lists', 43 + build: () => MyListsCubit(listRepository: mockListRepository), 44 + setUp: () { 45 + when( 46 + () => mockListRepository.getLists( 47 + actor: actor, 48 + cursor: any(named: 'cursor'), 49 + limit: 25, 50 + includeReference: any(named: 'includeReference'), 51 + ), 52 + ).thenAnswer((_) async => ListsResult(lists: [curationList, moderationList], cursor: 'cursor-1')); 53 + }, 54 + act: (cubit) => cubit.load(actor: actor, limit: 25), 55 + expect: () => [ 56 + const MyListsState.loading(actor: actor, limit: 25), 57 + predicate<MyListsState>( 58 + (state) => 59 + state.status == MyListsStatus.loaded && 60 + state.curationLists.length == 1 && 61 + state.moderationLists.length == 1 && 62 + state.referenceLists.isEmpty && 63 + state.cursor == 'cursor-1', 64 + ), 65 + ], 66 + ); 67 + 68 + blocTest<MyListsCubit, MyListsState>( 69 + 'refreshes the current list collection', 70 + build: () => MyListsCubit(listRepository: mockListRepository), 71 + seed: () => MyListsState.loaded(actor: actor, lists: [curationList], cursor: 'cursor-1', hasMore: true), 72 + setUp: () { 73 + when( 74 + () => mockListRepository.getLists( 75 + actor: actor, 76 + cursor: any(named: 'cursor'), 77 + limit: 50, 78 + includeReference: any(named: 'includeReference'), 79 + ), 80 + ).thenAnswer((_) async => ListsResult(lists: [curationList, moderationList], cursor: null)); 81 + }, 82 + act: (cubit) => cubit.refresh(), 83 + expect: () => [ 84 + predicate<MyListsState>((state) => state.isRefreshing), 85 + predicate<MyListsState>((state) => !state.isRefreshing && state.lists.length == 2 && !state.hasMore), 86 + ], 87 + ); 88 + 89 + blocTest<MyListsCubit, MyListsState>( 90 + 'loads more pages for the active actor', 91 + build: () => MyListsCubit(listRepository: mockListRepository), 92 + seed: () => MyListsState.loaded(actor: actor, lists: [curationList], cursor: 'cursor-1', hasMore: true), 93 + setUp: () { 94 + when( 95 + () => mockListRepository.getLists( 96 + actor: actor, 97 + cursor: 'cursor-1', 98 + limit: 50, 99 + includeReference: any(named: 'includeReference'), 100 + ), 101 + ).thenAnswer((_) async => ListsResult(lists: [moderationList, referenceList], cursor: null)); 102 + }, 103 + act: (cubit) => cubit.loadMore(), 104 + expect: () => [ 105 + predicate<MyListsState>((state) => state.isLoadingMore), 106 + predicate<MyListsState>( 107 + (state) => !state.isLoadingMore && state.lists.length == 3 && state.referenceLists.single == referenceList, 108 + ), 109 + ], 110 + ); 111 + }); 112 + } 113 + 114 + ListView _buildList({required AtUri uri, required KnownListPurpose purpose, required String name}) { 115 + return ListView( 116 + uri: uri, 117 + cid: 'cid-${uri.rkey}', 118 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 119 + name: name, 120 + purpose: ListPurpose.knownValue(data: purpose), 121 + indexedAt: DateTime.utc(2026, 3, 21), 122 + ); 123 + }
+334
test/features/lists/data/list_repository_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bluesky/app_bsky_graph_defs.dart'; 5 + import 'package:bluesky/app_bsky_graph_getlists.dart'; 6 + import 'package:bluesky/app_bsky_graph_getlistswithmembership.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/features/lists/data/list_repository.dart'; 9 + 10 + void main() { 11 + late _FakeGraphService graph; 12 + late _FakeFeedService feed; 13 + late _FakeActorService actor; 14 + late ListRepository repository; 15 + 16 + final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); 17 + final listItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); 18 + final blockUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-1'); 19 + 20 + setUp(() { 21 + graph = _FakeGraphService(); 22 + feed = _FakeFeedService(); 23 + actor = _FakeActorService(); 24 + repository = ListRepository( 25 + bluesky: _FakeBlueskyClient(graph: graph, feed: feed, actor: actor), 26 + ); 27 + }); 28 + 29 + group('ListRepository', () { 30 + final listView = _buildListView(listUri); 31 + final listItem = ListItemView( 32 + uri: listItemUri, 33 + subject: const ProfileView(did: 'did:plc:member-1', handle: 'member1.bsky.social'), 34 + ); 35 + final feedPost = FeedViewPost( 36 + post: PostView( 37 + uri: AtUri.parse('at://did:plc:member-1/app.bsky.feed.post/post-1'), 38 + cid: 'cid-post', 39 + author: const ProfileViewBasic(did: 'did:plc:member-1', handle: 'member1.bsky.social'), 40 + record: { 41 + r'$type': 'app.bsky.feed.post', 42 + 'text': 'Hello from a list', 43 + 'createdAt': DateTime.utc(2026, 3, 21).toIso8601String(), 44 + }, 45 + indexedAt: DateTime.utc(2026, 3, 21), 46 + ), 47 + ); 48 + 49 + test('getLists requests curation and moderation lists by default', () async { 50 + graph.getListsResult = _FakeListsData(lists: [listView], cursor: 'cursor-1'); 51 + 52 + final result = await repository.getLists(actor: 'did:plc:creator', limit: 25); 53 + 54 + expect(result.lists, [listView]); 55 + expect(result.cursor, 'cursor-1'); 56 + expect(graph.lastGetListsActor, 'did:plc:creator'); 57 + expect(graph.lastGetListsLimit, 25); 58 + expect(graph.lastGetListsPurposes?.map((purpose) => purpose.toJson()).toList(), ['curatelist', 'modlist']); 59 + }); 60 + 61 + test('getList returns the hydrated list and members', () async { 62 + graph.getListResult = _FakeListData(list: listView, items: [listItem], cursor: null); 63 + 64 + final result = await repository.getList(listUri: listUri); 65 + 66 + expect(result.list, listView); 67 + expect(result.items, [listItem]); 68 + expect(graph.lastGetListUri, listUri); 69 + }); 70 + 71 + test('getListFeed returns feed posts and cursor', () async { 72 + feed.getListFeedResult = _FakeListFeedData(feed: [feedPost], cursor: 'cursor-2'); 73 + 74 + final result = await repository.getListFeed(listUri: listUri); 75 + 76 + expect(result.posts, [feedPost]); 77 + expect(result.cursor, 'cursor-2'); 78 + expect(feed.lastListUri, listUri); 79 + }); 80 + 81 + test('getListsWithMembership returns membership records', () async { 82 + graph.getListsWithMembershipResult = _FakeListsWithMembershipData( 83 + listsWithMembership: [ListWithMembership(list: listView, listItem: listItem)], 84 + cursor: null, 85 + ); 86 + 87 + final result = await repository.getListsWithMembership(actor: 'did:plc:member-1'); 88 + 89 + expect(result.lists.length, 1); 90 + expect(result.lists.single.listItem, listItem); 91 + expect(graph.lastGetListsWithMembershipPurposes?.map((purpose) => purpose.toJson()).toList(), [ 92 + 'curatelist', 93 + 'modlist', 94 + ]); 95 + }); 96 + 97 + test('searchActorsTypeahead returns matching actors', () async { 98 + actor.searchActorsResult = const _FakeActorsData( 99 + actors: [ProfileViewBasic(did: 'did:plc:member-1', handle: 'member1.bsky.social')], 100 + ); 101 + 102 + final result = await repository.searchActorsTypeahead(query: 'member', limit: 5); 103 + 104 + expect(result.single.did, 'did:plc:member-1'); 105 + expect(actor.lastQuery, 'member'); 106 + expect(actor.lastLimit, 5); 107 + }); 108 + 109 + test('add and remove list members call the record accessors', () async { 110 + graph.listitem.createdUri = listItemUri; 111 + 112 + final createdUri = await repository.addListItem(listUri: listUri, subjectDid: 'did:plc:member-1'); 113 + await repository.removeListItem(listItemUri: listItemUri); 114 + 115 + expect(createdUri, listItemUri.toString()); 116 + expect(graph.listitem.lastCreatedList, listUri); 117 + expect(graph.listitem.lastCreatedSubject, 'did:plc:member-1'); 118 + expect(graph.listitem.lastDeletedRkey, listItemUri.rkey); 119 + }); 120 + 121 + test('mute and unmute list call graph endpoints', () async { 122 + await repository.muteList(listUri: listUri); 123 + await repository.unmuteList(listUri: listUri); 124 + 125 + expect(graph.lastMutedList, listUri); 126 + expect(graph.lastUnmutedList, listUri); 127 + }); 128 + 129 + test('block and unblock list call listblock accessors', () async { 130 + graph.listblock.createdUri = blockUri; 131 + 132 + final createdUri = await repository.blockList(listUri: listUri); 133 + await repository.unblockList(blockUri: blockUri); 134 + 135 + expect(createdUri, blockUri.toString()); 136 + expect(graph.listblock.lastCreatedSubject, listUri); 137 + expect(graph.listblock.lastDeletedRkey, blockUri.rkey); 138 + }); 139 + }); 140 + } 141 + 142 + ListView _buildListView(AtUri uri) { 143 + return ListView( 144 + uri: uri, 145 + cid: 'cid-${uri.rkey}', 146 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 147 + name: 'Core List', 148 + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsCuratelist), 149 + indexedAt: DateTime.utc(2026, 3, 21), 150 + ); 151 + } 152 + 153 + class _FakeBlueskyClient { 154 + _FakeBlueskyClient({required this.graph, required this.feed, required this.actor}); 155 + 156 + final _FakeGraphService graph; 157 + final _FakeFeedService feed; 158 + final _FakeActorService actor; 159 + } 160 + 161 + class _FakeGraphService { 162 + _FakeGraphService() : listitem = _FakeListitemAccessor(), listblock = _FakeListblockAccessor(); 163 + 164 + _FakeListsData? getListsResult; 165 + _FakeListData? getListResult; 166 + _FakeListsWithMembershipData? getListsWithMembershipResult; 167 + 168 + String? lastGetListsActor; 169 + int? lastGetListsLimit; 170 + List<GraphGetListsPurposes>? lastGetListsPurposes; 171 + AtUri? lastGetListUri; 172 + AtUri? lastMutedList; 173 + AtUri? lastUnmutedList; 174 + List<GraphGetListsWithMembershipPurposes>? lastGetListsWithMembershipPurposes; 175 + 176 + final _FakeListitemAccessor listitem; 177 + final _FakeListblockAccessor listblock; 178 + 179 + Future<_FakeResponse<_FakeListsData>> getLists({ 180 + required String actor, 181 + int? limit, 182 + String? cursor, 183 + List<GraphGetListsPurposes>? purposes, 184 + Map<String, String>? $headers, 185 + }) async { 186 + lastGetListsActor = actor; 187 + lastGetListsLimit = limit; 188 + lastGetListsPurposes = purposes; 189 + return _FakeResponse(getListsResult!); 190 + } 191 + 192 + Future<_FakeResponse<_FakeListData>> getList({ 193 + required AtUri list, 194 + int? limit, 195 + String? cursor, 196 + Map<String, String>? $headers, 197 + }) async { 198 + lastGetListUri = list; 199 + return _FakeResponse(getListResult!); 200 + } 201 + 202 + Future<_FakeResponse<_FakeListsWithMembershipData>> getListsWithMembership({ 203 + required String actor, 204 + int? limit, 205 + String? cursor, 206 + List<GraphGetListsWithMembershipPurposes>? purposes, 207 + Map<String, String>? $headers, 208 + }) async { 209 + lastGetListsWithMembershipPurposes = purposes; 210 + return _FakeResponse(getListsWithMembershipResult!); 211 + } 212 + 213 + Future<void> muteActorList({required AtUri list, Map<String, String>? $headers}) async { 214 + lastMutedList = list; 215 + } 216 + 217 + Future<void> unmuteActorList({required AtUri list, Map<String, String>? $headers}) async { 218 + lastUnmutedList = list; 219 + } 220 + } 221 + 222 + class _FakeFeedService { 223 + _FakeListFeedData? getListFeedResult; 224 + AtUri? lastListUri; 225 + 226 + Future<_FakeResponse<_FakeListFeedData>> getListFeed({ 227 + required AtUri list, 228 + int? limit, 229 + String? cursor, 230 + Map<String, String>? $headers, 231 + }) async { 232 + lastListUri = list; 233 + return _FakeResponse(getListFeedResult!); 234 + } 235 + } 236 + 237 + class _FakeActorService { 238 + _FakeActorsData? searchActorsResult; 239 + String? lastQuery; 240 + int? lastLimit; 241 + 242 + Future<_FakeResponse<_FakeActorsData>> searchActorsTypeahead({ 243 + required String q, 244 + int? limit, 245 + Map<String, String>? $headers, 246 + }) async { 247 + lastQuery = q; 248 + lastLimit = limit; 249 + return _FakeResponse(searchActorsResult!); 250 + } 251 + } 252 + 253 + class _FakeListitemAccessor { 254 + AtUri createdUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-created'); 255 + AtUri? lastCreatedList; 256 + String? lastCreatedSubject; 257 + String? lastDeletedRkey; 258 + 259 + Future<_FakeResponse<_FakeUriData>> create({ 260 + required String subject, 261 + required AtUri list, 262 + DateTime? createdAt, 263 + }) async { 264 + lastCreatedList = list; 265 + lastCreatedSubject = subject; 266 + return _FakeResponse(_FakeUriData(createdUri)); 267 + } 268 + 269 + Future<void> delete({required String rkey}) async { 270 + lastDeletedRkey = rkey; 271 + } 272 + } 273 + 274 + class _FakeListblockAccessor { 275 + AtUri createdUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-created'); 276 + AtUri? lastCreatedSubject; 277 + String? lastDeletedRkey; 278 + 279 + Future<_FakeResponse<_FakeUriData>> create({required AtUri subject, DateTime? createdAt}) async { 280 + lastCreatedSubject = subject; 281 + return _FakeResponse(_FakeUriData(createdUri)); 282 + } 283 + 284 + Future<void> delete({required String rkey}) async { 285 + lastDeletedRkey = rkey; 286 + } 287 + } 288 + 289 + class _FakeResponse<T> { 290 + _FakeResponse(this.data); 291 + 292 + final T data; 293 + } 294 + 295 + class _FakeListsData { 296 + const _FakeListsData({required this.lists, this.cursor}); 297 + 298 + final List<ListView> lists; 299 + final String? cursor; 300 + } 301 + 302 + class _FakeListData { 303 + const _FakeListData({required this.list, required this.items, this.cursor}); 304 + 305 + final ListView list; 306 + final List<ListItemView> items; 307 + final String? cursor; 308 + } 309 + 310 + class _FakeListsWithMembershipData { 311 + const _FakeListsWithMembershipData({required this.listsWithMembership, this.cursor}); 312 + 313 + final List<ListWithMembership> listsWithMembership; 314 + final String? cursor; 315 + } 316 + 317 + class _FakeListFeedData { 318 + const _FakeListFeedData({required this.feed, this.cursor}); 319 + 320 + final List<FeedViewPost> feed; 321 + final String? cursor; 322 + } 323 + 324 + class _FakeActorsData { 325 + const _FakeActorsData({required this.actors}); 326 + 327 + final List<ProfileViewBasic> actors; 328 + } 329 + 330 + class _FakeUriData { 331 + const _FakeUriData(this.uri); 332 + 333 + final AtUri uri; 334 + }