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: ProfileContextCubit and ProfileContextState with loading logic for blocked and lists counts with ProfileContextScreen

+1620 -10
+6 -6
docs/tasks/phase-5.md
··· 97 97 98 98 ### Cubit 99 99 100 - - [ ] `ProfileContextCubit` - manages tab state, loads counts on init for all three tabs 101 - - [ ] `ProfileContextState` - fields: `blockedByCount`, `blockingCount`, `listsOnCount`, per-tab `status` (initial/loading/loaded/error), per-tab item list + cursor 102 - - [ ] `loadBlockedBy({cursor})` - fetches page of blocked-by profiles, appends to state 103 - - [ ] `loadBlocking({cursor})` - fetches page of blocking profiles, appends to state 104 - - [ ] `loadListsOn({cursor})` - fetches page of lists, appends to state 105 - - [ ] Handle own-profile vs other-profile: blocking tab only available for own profile 100 + - [x] `ProfileContextCubit` - manages tab state, loads counts on init for all three tabs 101 + - [x] `ProfileContextState` - fields: `blockedByCount`, `blockingCount`, `listsOnCount`, per-tab `status` (initial/loading/loaded/error), per-tab item list + cursor 102 + - [x] `loadBlockedBy({cursor})` - fetches page of blocked-by profiles, appends to state 103 + - [x] `loadBlocking({cursor})` - fetches page of blocking profiles, appends to state 104 + - [x] `loadListsOn({cursor})` - fetches page of lists, appends to state 105 + - [x] Handle own-profile vs other-profile: blocking tab only available for own profile 106 106 107 107 ### UI 108 108
+22
lib/core/router/app_router.dart
··· 45 45 import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; 46 46 import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 47 47 import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 48 + import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; 49 + import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 50 + import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 51 + import 'package:lazurite/core/network/constellation_client.dart'; 52 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 48 53 import 'package:lazurite/features/settings/presentation/about_screen.dart'; 49 54 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 50 55 ··· 180 185 }, 181 186 ), 182 187 ], 188 + ), 189 + GoRoute( 190 + path: '/profile-context', 191 + builder: (context, state) { 192 + final did = state.uri.queryParameters['did'] ?? ''; 193 + final handle = state.uri.queryParameters['handle'] ?? ''; 194 + final isOwnProfile = did == context.read<String>(); 195 + final constellationUrl = context.read<SettingsCubit>().state.constellationUrl; 196 + final repository = ProfileContextRepository( 197 + bluesky: context.read<Bluesky>(), 198 + constellationClient: ConstellationClient(baseUrl: constellationUrl), 199 + ); 200 + return BlocProvider( 201 + create: (_) => ProfileContextCubit(repository: repository, did: did, isOwnProfile: isOwnProfile), 202 + child: ProfileContextScreen(handle: handle), 203 + ); 204 + }, 183 205 ), 184 206 StatefulShellRoute.indexedStack( 185 207 builder: (context, state, navigationShell) {
+154
lib/features/profile/cubit/profile_context_cubit.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.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/core/logging/app_logger.dart'; 6 + import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 7 + 8 + part 'profile_context_state.dart'; 9 + 10 + class ProfileContextCubit extends Cubit<ProfileContextState> { 11 + ProfileContextCubit({required ProfileContextRepository repository, required String did, required bool isOwnProfile}) 12 + : _repository = repository, 13 + super(ProfileContextState.initial(did: did, isOwnProfile: isOwnProfile)); 14 + 15 + final ProfileContextRepository _repository; 16 + 17 + /// Loads blocked-by and lists-on counts in parallel for tab header badges. 18 + Future<void> init() async { 19 + try { 20 + final results = await Future.wait([ 21 + _repository.getBlockedByCount(state.did), 22 + _repository.getListsOnCount(state.did), 23 + ]); 24 + emit(state.copyWith(blockedByCount: results[0], listsOnCount: results[1])); 25 + } catch (error) { 26 + log.w('failed to load initial counts: $error'); 27 + } 28 + } 29 + 30 + /// Fetches a page of profiles that have blocked the viewed user and appends 31 + /// them to state. Pass [cursor] from [state.blockedByCursor] for pagination. 32 + Future<void> loadBlockedBy({String? cursor}) async { 33 + if (state.blockedByStatus == ProfileContextTabStatus.loading) return; 34 + 35 + emit(state.copyWith(blockedByStatus: ProfileContextTabStatus.loading, blockedByError: null)); 36 + 37 + try { 38 + final result = await _repository.getBlockedByProfiles(state.did, cursor: cursor); 39 + emit( 40 + state.copyWith( 41 + blockedByStatus: ProfileContextTabStatus.loaded, 42 + blockedByProfiles: [...state.blockedByProfiles, ...result.profiles], 43 + blockedByCount: result.total, 44 + blockedByCursor: result.cursor, 45 + blockedByHasMore: result.cursor != null, 46 + ), 47 + ); 48 + } catch (error) { 49 + emit( 50 + state.copyWith( 51 + blockedByStatus: ProfileContextTabStatus.error, 52 + blockedByError: 'Failed to load blocked-by profiles: $error', 53 + ), 54 + ); 55 + } 56 + } 57 + 58 + /// Fetches a page of profiles that the viewed user is blocking and appends 59 + /// them to state. Only available when [state.isOwnProfile] is true. 60 + Future<void> loadBlocking({String? cursor}) async { 61 + if (!state.isOwnProfile) return; 62 + if (state.blockingStatus == ProfileContextTabStatus.loading) return; 63 + 64 + emit(state.copyWith(blockingStatus: ProfileContextTabStatus.loading, blockingError: null)); 65 + 66 + try { 67 + final result = await _repository.getBlockingProfiles(state.did, cursor: cursor); 68 + final merged = [...state.blockingProfiles, ...result.profiles]; 69 + emit( 70 + state.copyWith( 71 + blockingStatus: ProfileContextTabStatus.loaded, 72 + blockingProfiles: merged, 73 + blockingCount: merged.length, 74 + blockingCursor: result.cursor, 75 + blockingHasMore: result.cursor != null, 76 + ), 77 + ); 78 + } catch (error) { 79 + emit( 80 + state.copyWith( 81 + blockingStatus: ProfileContextTabStatus.error, 82 + blockingError: 'Failed to load blocking profiles: $error', 83 + ), 84 + ); 85 + } 86 + } 87 + 88 + /// Fetches a page of lists the viewed user is on and appends them to state. 89 + /// Pass [cursor] from [state.listsOnCursor] for pagination. 90 + Future<void> loadListsOn({String? cursor}) async { 91 + if (state.listsOnStatus == ProfileContextTabStatus.loading) return; 92 + 93 + emit(state.copyWith(listsOnStatus: ProfileContextTabStatus.loading, listsOnError: null)); 94 + 95 + try { 96 + final result = await _repository.getListsOn(state.did, cursor: cursor); 97 + emit( 98 + state.copyWith( 99 + listsOnStatus: ProfileContextTabStatus.loaded, 100 + listsOn: [...state.listsOn, ...result.lists], 101 + listsOnCount: result.total, 102 + listsOnCursor: result.cursor, 103 + listsOnHasMore: result.cursor != null, 104 + ), 105 + ); 106 + } catch (error) { 107 + emit(state.copyWith(listsOnStatus: ProfileContextTabStatus.error, listsOnError: 'Failed to load lists: $error')); 108 + } 109 + } 110 + 111 + /// Resets the blocked-by list and reloads from the first page. 112 + Future<void> refreshBlockedBy() async { 113 + emit( 114 + state.copyWith( 115 + blockedByProfiles: [], 116 + blockedByCursor: null, 117 + blockedByHasMore: false, 118 + blockedByStatus: ProfileContextTabStatus.initial, 119 + blockedByError: null, 120 + ), 121 + ); 122 + await loadBlockedBy(); 123 + } 124 + 125 + /// Resets the blocking list and reloads from the first page. 126 + Future<void> refreshBlocking() async { 127 + if (!state.isOwnProfile) return; 128 + emit( 129 + state.copyWith( 130 + blockingProfiles: [], 131 + blockingCursor: null, 132 + blockingHasMore: false, 133 + blockingStatus: ProfileContextTabStatus.initial, 134 + blockingError: null, 135 + blockingCount: 0, 136 + ), 137 + ); 138 + await loadBlocking(); 139 + } 140 + 141 + /// Resets the lists-on list and reloads from the first page. 142 + Future<void> refreshListsOn() async { 143 + emit( 144 + state.copyWith( 145 + listsOn: [], 146 + listsOnCursor: null, 147 + listsOnHasMore: false, 148 + listsOnStatus: ProfileContextTabStatus.initial, 149 + listsOnError: null, 150 + ), 151 + ); 152 + await loadListsOn(); 153 + } 154 + }
+132
lib/features/profile/cubit/profile_context_state.dart
··· 1 + part of 'profile_context_cubit.dart'; 2 + 3 + enum ProfileContextTabStatus { initial, loading, loaded, error } 4 + 5 + const _profileContextNoValue = Object(); 6 + 7 + class ProfileContextState extends Equatable { 8 + const ProfileContextState._({ 9 + required this.did, 10 + required this.isOwnProfile, 11 + this.blockedByCount = 0, 12 + this.blockingCount = 0, 13 + this.listsOnCount = 0, 14 + this.blockedByStatus = ProfileContextTabStatus.initial, 15 + this.blockedByProfiles = const [], 16 + this.blockedByCursor, 17 + this.blockedByHasMore = false, 18 + this.blockedByError, 19 + this.blockingStatus = ProfileContextTabStatus.initial, 20 + this.blockingProfiles = const [], 21 + this.blockingCursor, 22 + this.blockingHasMore = false, 23 + this.blockingError, 24 + this.listsOnStatus = ProfileContextTabStatus.initial, 25 + this.listsOn = const [], 26 + this.listsOnCursor, 27 + this.listsOnHasMore = false, 28 + this.listsOnError, 29 + }); 30 + 31 + const ProfileContextState.initial({required String did, required bool isOwnProfile}) 32 + : this._(did: did, isOwnProfile: isOwnProfile); 33 + 34 + final String did; 35 + final bool isOwnProfile; 36 + 37 + final int blockedByCount; 38 + final int blockingCount; 39 + final int listsOnCount; 40 + 41 + final ProfileContextTabStatus blockedByStatus; 42 + final List<ProfileView> blockedByProfiles; 43 + final String? blockedByCursor; 44 + final bool blockedByHasMore; 45 + final String? blockedByError; 46 + 47 + final ProfileContextTabStatus blockingStatus; 48 + final List<ProfileView> blockingProfiles; 49 + final String? blockingCursor; 50 + final bool blockingHasMore; 51 + final String? blockingError; 52 + 53 + final ProfileContextTabStatus listsOnStatus; 54 + final List<ListView> listsOn; 55 + final String? listsOnCursor; 56 + final bool listsOnHasMore; 57 + final String? listsOnError; 58 + 59 + ProfileContextState copyWith({ 60 + int? blockedByCount, 61 + int? blockingCount, 62 + int? listsOnCount, 63 + ProfileContextTabStatus? blockedByStatus, 64 + List<ProfileView>? blockedByProfiles, 65 + Object? blockedByCursor = _profileContextNoValue, 66 + bool? blockedByHasMore, 67 + Object? blockedByError = _profileContextNoValue, 68 + ProfileContextTabStatus? blockingStatus, 69 + List<ProfileView>? blockingProfiles, 70 + Object? blockingCursor = _profileContextNoValue, 71 + bool? blockingHasMore, 72 + Object? blockingError = _profileContextNoValue, 73 + ProfileContextTabStatus? listsOnStatus, 74 + List<ListView>? listsOn, 75 + Object? listsOnCursor = _profileContextNoValue, 76 + bool? listsOnHasMore, 77 + Object? listsOnError = _profileContextNoValue, 78 + }) { 79 + return ProfileContextState._( 80 + did: did, 81 + isOwnProfile: isOwnProfile, 82 + blockedByCount: blockedByCount ?? this.blockedByCount, 83 + blockingCount: blockingCount ?? this.blockingCount, 84 + listsOnCount: listsOnCount ?? this.listsOnCount, 85 + blockedByStatus: blockedByStatus ?? this.blockedByStatus, 86 + blockedByProfiles: blockedByProfiles ?? this.blockedByProfiles, 87 + blockedByCursor: identical(blockedByCursor, _profileContextNoValue) 88 + ? this.blockedByCursor 89 + : blockedByCursor as String?, 90 + blockedByHasMore: blockedByHasMore ?? this.blockedByHasMore, 91 + blockedByError: identical(blockedByError, _profileContextNoValue) 92 + ? this.blockedByError 93 + : blockedByError as String?, 94 + blockingStatus: blockingStatus ?? this.blockingStatus, 95 + blockingProfiles: blockingProfiles ?? this.blockingProfiles, 96 + blockingCursor: identical(blockingCursor, _profileContextNoValue) 97 + ? this.blockingCursor 98 + : blockingCursor as String?, 99 + blockingHasMore: blockingHasMore ?? this.blockingHasMore, 100 + blockingError: identical(blockingError, _profileContextNoValue) ? this.blockingError : blockingError as String?, 101 + listsOnStatus: listsOnStatus ?? this.listsOnStatus, 102 + listsOn: listsOn ?? this.listsOn, 103 + listsOnCursor: identical(listsOnCursor, _profileContextNoValue) ? this.listsOnCursor : listsOnCursor as String?, 104 + listsOnHasMore: listsOnHasMore ?? this.listsOnHasMore, 105 + listsOnError: identical(listsOnError, _profileContextNoValue) ? this.listsOnError : listsOnError as String?, 106 + ); 107 + } 108 + 109 + @override 110 + List<Object?> get props => [ 111 + did, 112 + isOwnProfile, 113 + blockedByCount, 114 + blockingCount, 115 + listsOnCount, 116 + blockedByStatus, 117 + blockedByProfiles, 118 + blockedByCursor, 119 + blockedByHasMore, 120 + blockedByError, 121 + blockingStatus, 122 + blockingProfiles, 123 + blockingCursor, 124 + blockingHasMore, 125 + blockingError, 126 + listsOnStatus, 127 + listsOn, 128 + listsOnCursor, 129 + listsOnHasMore, 130 + listsOnError, 131 + ]; 132 + }
+5
lib/features/profile/data/profile_context_repository.dart
··· 16 16 return _constellation.getBacklinksCount(did, 'app.bsky.graph.block:subject'); 17 17 } 18 18 19 + /// Returns the number of lists that [did] is a member of. 20 + Future<int> getListsOnCount(String did) async { 21 + return _constellation.getBacklinksCount(did, 'app.bsky.graph.listitem:subject'); 22 + } 23 + 19 24 /// Returns a page of profiles that have blocked [did], along with the total 20 25 /// count and a cursor for the next page. 21 26 Future<({List<ProfileView> profiles, String? cursor, int total})> getBlockedByProfiles(
+533
lib/features/profile/presentation/profile_context_screen.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; 6 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 7 + import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; 8 + 9 + class ProfileContextScreen extends StatefulWidget { 10 + const ProfileContextScreen({super.key, required this.handle}); 11 + 12 + /// The handle of the profile being viewed, shown as a subtitle in the AppBar. 13 + final String handle; 14 + 15 + @override 16 + State<ProfileContextScreen> createState() => _ProfileContextScreenState(); 17 + } 18 + 19 + class _ProfileContextScreenState extends State<ProfileContextScreen> with SingleTickerProviderStateMixin { 20 + late final TabController _tabController; 21 + 22 + // Track whether each tab has had its initial load triggered. 23 + bool _blockingLoaded = false; 24 + bool _listsOnLoaded = false; 25 + 26 + @override 27 + void initState() { 28 + super.initState(); 29 + _tabController = TabController(length: 3, vsync: this); 30 + _tabController.addListener(_onTabChanged); 31 + // Kick off the counts fetch immediately. 32 + context.read<ProfileContextCubit>().init(); 33 + } 34 + 35 + @override 36 + void dispose() { 37 + _tabController 38 + ..removeListener(_onTabChanged) 39 + ..dispose(); 40 + super.dispose(); 41 + } 42 + 43 + void _onTabChanged() { 44 + if (_tabController.indexIsChanging) return; 45 + final cubit = context.read<ProfileContextCubit>(); 46 + final index = _tabController.index; 47 + if (index == 1 && !_blockingLoaded) { 48 + _blockingLoaded = true; 49 + if (cubit.state.blockingStatus == ProfileContextTabStatus.initial) { 50 + cubit.loadBlocking(); 51 + } 52 + } else if (index == 2 && !_listsOnLoaded) { 53 + _listsOnLoaded = true; 54 + if (cubit.state.listsOnStatus == ProfileContextTabStatus.initial) { 55 + cubit.loadListsOn(); 56 + } 57 + } 58 + } 59 + 60 + @override 61 + Widget build(BuildContext context) { 62 + return BlocBuilder<ProfileContextCubit, ProfileContextState>( 63 + builder: (context, state) { 64 + return Scaffold( 65 + appBar: AppBar( 66 + title: Column( 67 + crossAxisAlignment: CrossAxisAlignment.start, 68 + children: [ 69 + const Text('Profile Context'), 70 + Text( 71 + '@${widget.handle}', 72 + style: Theme.of( 73 + context, 74 + ).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 75 + ), 76 + ], 77 + ), 78 + bottom: TabBar( 79 + controller: _tabController, 80 + tabs: [ 81 + Tab(text: 'Blocked By${state.blockedByCount > 0 ? ' (${state.blockedByCount})' : ''}'), 82 + Tab(text: 'Blocking${state.blockingCount > 0 ? ' (${state.blockingCount})' : ''}'), 83 + Tab(text: 'Lists${state.listsOnCount > 0 ? ' (${state.listsOnCount})' : ''}'), 84 + ], 85 + ), 86 + ), 87 + body: TabBarView( 88 + controller: _tabController, 89 + children: [ 90 + _BlockedByTab(state: state), 91 + _BlockingTab(state: state), 92 + _ListsOnTab(state: state), 93 + ], 94 + ), 95 + ); 96 + }, 97 + ); 98 + } 99 + } 100 + 101 + // --------------------------------------------------------------------------- 102 + // Blocked By tab 103 + // --------------------------------------------------------------------------- 104 + 105 + class _BlockedByTab extends StatelessWidget { 106 + const _BlockedByTab({required this.state}); 107 + 108 + final ProfileContextState state; 109 + 110 + @override 111 + Widget build(BuildContext context) { 112 + final cubit = context.read<ProfileContextCubit>(); 113 + 114 + return RefreshIndicator( 115 + onRefresh: cubit.refreshBlockedBy, 116 + child: CustomScrollView( 117 + slivers: [ 118 + // Contextualizing note at the top. 119 + const SliverToBoxAdapter( 120 + child: Padding( 121 + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), 122 + child: Text( 123 + 'Blocks are a normal part of social media. ' 124 + 'This data is public on the AT Protocol.', 125 + textAlign: TextAlign.center, 126 + ), 127 + ), 128 + ), 129 + // Count header + expand button. 130 + SliverToBoxAdapter( 131 + child: Padding( 132 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 133 + child: Row( 134 + children: [ 135 + Text( 136 + '${state.blockedByCount} account${state.blockedByCount == 1 ? '' : 's'}', 137 + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 138 + ), 139 + const Spacer(), 140 + if (state.blockedByStatus == ProfileContextTabStatus.initial) 141 + TextButton.icon( 142 + key: const Key('blocked_by_show_accounts'), 143 + onPressed: () => cubit.loadBlockedBy(), 144 + icon: const Icon(Icons.expand_more), 145 + label: const Text('Show accounts'), 146 + ), 147 + ], 148 + ), 149 + ), 150 + ), 151 + // Content based on status. 152 + if (state.blockedByStatus == ProfileContextTabStatus.loading && state.blockedByProfiles.isEmpty) 153 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 154 + else if (state.blockedByStatus == ProfileContextTabStatus.error && state.blockedByProfiles.isEmpty) 155 + SliverFillRemaining( 156 + hasScrollBody: false, 157 + child: Center( 158 + child: _ErrorRetry( 159 + message: state.blockedByError ?? 'Failed to load accounts', 160 + onRetry: () => cubit.loadBlockedBy(), 161 + ), 162 + ), 163 + ) 164 + else if (state.blockedByStatus == ProfileContextTabStatus.loaded && state.blockedByProfiles.isEmpty) 165 + const SliverFillRemaining( 166 + hasScrollBody: false, 167 + child: Center(child: Text('No accounts have blocked this user')), 168 + ) 169 + else ...[ 170 + SliverList.builder( 171 + itemCount: state.blockedByProfiles.length, 172 + itemBuilder: (context, index) { 173 + final profile = state.blockedByProfiles[index]; 174 + return _ProfileTile( 175 + key: ValueKey('blocked_by_${profile.did}'), 176 + profile: profile, 177 + onTap: () => context.push('/profile/view?actor=${profile.did}'), 178 + ); 179 + }, 180 + ), 181 + if (state.blockedByStatus == ProfileContextTabStatus.loading) 182 + const SliverToBoxAdapter( 183 + child: Padding( 184 + padding: EdgeInsets.all(16), 185 + child: Center(child: CircularProgressIndicator()), 186 + ), 187 + ) 188 + else if (state.blockedByStatus == ProfileContextTabStatus.error) 189 + SliverToBoxAdapter( 190 + child: Padding( 191 + padding: const EdgeInsets.all(16), 192 + child: _ErrorRetry( 193 + message: state.blockedByError ?? 'Failed to load more', 194 + onRetry: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), 195 + ), 196 + ), 197 + ) 198 + else if (state.blockedByHasMore) 199 + SliverToBoxAdapter( 200 + child: Padding( 201 + padding: const EdgeInsets.all(16), 202 + child: Center( 203 + child: TextButton( 204 + onPressed: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), 205 + child: const Text('Load more'), 206 + ), 207 + ), 208 + ), 209 + ), 210 + ], 211 + ], 212 + ), 213 + ); 214 + } 215 + } 216 + 217 + // --------------------------------------------------------------------------- 218 + // Blocking tab 219 + // --------------------------------------------------------------------------- 220 + 221 + class _BlockingTab extends StatelessWidget { 222 + const _BlockingTab({required this.state}); 223 + 224 + final ProfileContextState state; 225 + 226 + @override 227 + Widget build(BuildContext context) { 228 + final cubit = context.read<ProfileContextCubit>(); 229 + 230 + if (!state.isOwnProfile) { 231 + return const Center( 232 + child: Padding( 233 + padding: EdgeInsets.all(24), 234 + child: Text( 235 + 'Blocking information is only available when viewing your own profile.', 236 + textAlign: TextAlign.center, 237 + ), 238 + ), 239 + ); 240 + } 241 + 242 + return RefreshIndicator( 243 + onRefresh: cubit.refreshBlocking, 244 + child: CustomScrollView( 245 + slivers: [ 246 + if (state.blockingStatus == ProfileContextTabStatus.initial || 247 + (state.blockingStatus == ProfileContextTabStatus.loading && state.blockingProfiles.isEmpty)) 248 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 249 + else if (state.blockingStatus == ProfileContextTabStatus.error && state.blockingProfiles.isEmpty) 250 + SliverFillRemaining( 251 + hasScrollBody: false, 252 + child: Center( 253 + child: _ErrorRetry( 254 + message: state.blockingError ?? 'Failed to load accounts', 255 + onRetry: () => cubit.loadBlocking(), 256 + ), 257 + ), 258 + ) 259 + else if (state.blockingStatus == ProfileContextTabStatus.loaded && state.blockingProfiles.isEmpty) 260 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not blocking anyone'))) 261 + else ...[ 262 + SliverList.builder( 263 + itemCount: state.blockingProfiles.length, 264 + itemBuilder: (context, index) { 265 + final profile = state.blockingProfiles[index]; 266 + return _ProfileTile( 267 + key: ValueKey('blocking_${profile.did}'), 268 + profile: profile, 269 + onTap: () => context.push('/profile/view?actor=${profile.did}'), 270 + ); 271 + }, 272 + ), 273 + if (state.blockingStatus == ProfileContextTabStatus.loading) 274 + const SliverToBoxAdapter( 275 + child: Padding( 276 + padding: EdgeInsets.all(16), 277 + child: Center(child: CircularProgressIndicator()), 278 + ), 279 + ) 280 + else if (state.blockingStatus == ProfileContextTabStatus.error) 281 + SliverToBoxAdapter( 282 + child: Padding( 283 + padding: const EdgeInsets.all(16), 284 + child: _ErrorRetry( 285 + message: state.blockingError ?? 'Failed to load more', 286 + onRetry: () => cubit.loadBlocking(cursor: state.blockingCursor), 287 + ), 288 + ), 289 + ) 290 + else if (state.blockingHasMore) 291 + SliverToBoxAdapter( 292 + child: Padding( 293 + padding: const EdgeInsets.all(16), 294 + child: Center( 295 + child: TextButton( 296 + onPressed: () => cubit.loadBlocking(cursor: state.blockingCursor), 297 + child: const Text('Load more'), 298 + ), 299 + ), 300 + ), 301 + ), 302 + ], 303 + ], 304 + ), 305 + ); 306 + } 307 + } 308 + 309 + // --------------------------------------------------------------------------- 310 + // Lists On tab 311 + // --------------------------------------------------------------------------- 312 + 313 + class _ListsOnTab extends StatelessWidget { 314 + const _ListsOnTab({required this.state}); 315 + 316 + final ProfileContextState state; 317 + 318 + @override 319 + Widget build(BuildContext context) { 320 + final cubit = context.read<ProfileContextCubit>(); 321 + 322 + return RefreshIndicator( 323 + onRefresh: cubit.refreshListsOn, 324 + child: CustomScrollView( 325 + slivers: [ 326 + if (state.listsOnStatus == ProfileContextTabStatus.initial || 327 + (state.listsOnStatus == ProfileContextTabStatus.loading && state.listsOn.isEmpty)) 328 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 329 + else if (state.listsOnStatus == ProfileContextTabStatus.error && state.listsOn.isEmpty) 330 + SliverFillRemaining( 331 + hasScrollBody: false, 332 + child: Center( 333 + child: _ErrorRetry( 334 + message: state.listsOnError ?? 'Failed to load lists', 335 + onRetry: () => cubit.loadListsOn(), 336 + ), 337 + ), 338 + ) 339 + else if (state.listsOnStatus == ProfileContextTabStatus.loaded && state.listsOn.isEmpty) 340 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not on any lists'))) 341 + else ...[ 342 + SliverList.builder( 343 + itemCount: state.listsOn.length, 344 + itemBuilder: (context, index) { 345 + final list = state.listsOn[index]; 346 + return ListRowTile( 347 + key: ValueKey('list_on_${list.uri}'), 348 + list: list, 349 + onTap: () => context.push('/list?uri=${Uri.encodeComponent(list.uri.toString())}'), 350 + ); 351 + }, 352 + ), 353 + if (state.listsOnStatus == ProfileContextTabStatus.loading) 354 + const SliverToBoxAdapter( 355 + child: Padding( 356 + padding: EdgeInsets.all(16), 357 + child: Center(child: CircularProgressIndicator()), 358 + ), 359 + ) 360 + else if (state.listsOnStatus == ProfileContextTabStatus.error) 361 + SliverToBoxAdapter( 362 + child: Padding( 363 + padding: const EdgeInsets.all(16), 364 + child: _ErrorRetry( 365 + message: state.listsOnError ?? 'Failed to load more', 366 + onRetry: () => cubit.loadListsOn(cursor: state.listsOnCursor), 367 + ), 368 + ), 369 + ) 370 + else if (state.listsOnHasMore) 371 + SliverToBoxAdapter( 372 + child: Padding( 373 + padding: const EdgeInsets.all(16), 374 + child: Center( 375 + child: TextButton( 376 + onPressed: () => cubit.loadListsOn(cursor: state.listsOnCursor), 377 + child: const Text('Load more'), 378 + ), 379 + ), 380 + ), 381 + ), 382 + ], 383 + ], 384 + ), 385 + ); 386 + } 387 + } 388 + 389 + // --------------------------------------------------------------------------- 390 + // Reusable profile tile 391 + // --------------------------------------------------------------------------- 392 + 393 + class _ProfileTile extends StatelessWidget { 394 + const _ProfileTile({super.key, required this.profile, this.onTap}); 395 + 396 + final ProfileView profile; 397 + final VoidCallback? onTap; 398 + 399 + @override 400 + Widget build(BuildContext context) { 401 + final colorScheme = Theme.of(context).colorScheme; 402 + final displayName = profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle; 403 + final initials = _initials(displayName); 404 + 405 + return ListTile( 406 + leading: ModeratedAvatar(size: 40, imageUrl: profile.avatar, initials: initials), 407 + title: Text(displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 408 + subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 409 + onTap: onTap, 410 + ); 411 + } 412 + 413 + String _initials(String value) { 414 + final parts = value.trim().split(RegExp(r'\s+')); 415 + if (parts.isEmpty || parts.first.isEmpty) return '?'; 416 + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 417 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 418 + } 419 + } 420 + 421 + // --------------------------------------------------------------------------- 422 + // Shimmer skeleton 423 + // --------------------------------------------------------------------------- 424 + 425 + class _ShimmerList extends StatefulWidget { 426 + const _ShimmerList(); 427 + 428 + @override 429 + State<_ShimmerList> createState() => _ShimmerListState(); 430 + } 431 + 432 + class _ShimmerListState extends State<_ShimmerList> with SingleTickerProviderStateMixin { 433 + late final AnimationController _controller; 434 + late final Animation<double> _animation; 435 + 436 + @override 437 + void initState() { 438 + super.initState(); 439 + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat(reverse: true); 440 + _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); 441 + } 442 + 443 + @override 444 + void dispose() { 445 + _controller.dispose(); 446 + super.dispose(); 447 + } 448 + 449 + @override 450 + Widget build(BuildContext context) { 451 + final colorScheme = Theme.of(context).colorScheme; 452 + return AnimatedBuilder( 453 + animation: _animation, 454 + builder: (context, _) { 455 + final opacity = 0.3 + 0.4 * _animation.value; 456 + return Column( 457 + children: List.generate( 458 + 6, 459 + (_) => Padding( 460 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 461 + child: Row( 462 + children: [ 463 + Opacity( 464 + opacity: opacity, 465 + child: Container( 466 + width: 40, 467 + height: 40, 468 + decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), 469 + ), 470 + ), 471 + const SizedBox(width: 12), 472 + Expanded( 473 + child: Column( 474 + crossAxisAlignment: CrossAxisAlignment.start, 475 + children: [ 476 + Opacity( 477 + opacity: opacity, 478 + child: Container( 479 + height: 14, 480 + width: double.infinity, 481 + decoration: BoxDecoration( 482 + color: colorScheme.surfaceContainerHighest, 483 + borderRadius: BorderRadius.circular(4), 484 + ), 485 + ), 486 + ), 487 + const SizedBox(height: 6), 488 + Opacity( 489 + opacity: opacity, 490 + child: Container( 491 + height: 12, 492 + width: 120, 493 + decoration: BoxDecoration( 494 + color: colorScheme.surfaceContainerHighest, 495 + borderRadius: BorderRadius.circular(4), 496 + ), 497 + ), 498 + ), 499 + ], 500 + ), 501 + ), 502 + ], 503 + ), 504 + ), 505 + ), 506 + ); 507 + }, 508 + ); 509 + } 510 + } 511 + 512 + // --------------------------------------------------------------------------- 513 + // Error + retry widget 514 + // --------------------------------------------------------------------------- 515 + 516 + class _ErrorRetry extends StatelessWidget { 517 + const _ErrorRetry({required this.message, required this.onRetry}); 518 + 519 + final String message; 520 + final VoidCallback onRetry; 521 + 522 + @override 523 + Widget build(BuildContext context) { 524 + return Column( 525 + mainAxisSize: MainAxisSize.min, 526 + children: [ 527 + Text(message, textAlign: TextAlign.center), 528 + const SizedBox(height: 12), 529 + FilledButton(onPressed: onRetry, child: const Text('Retry')), 530 + ], 531 + ); 532 + } 533 + }
+39
lib/features/profile/presentation/profile_screen.dart
··· 152 152 ) 153 153 : const AppShellMenuButton(), 154 154 actions: [ 155 + if (profile != null && isOwnProfile) 156 + IconButton( 157 + key: const Key('profile_more_button'), 158 + icon: const Icon(Icons.more_vert), 159 + onPressed: () => _showOwnProfileMoreOptions(context, profile), 160 + ), 155 161 IconButton(icon: const Icon(Icons.settings_outlined), onPressed: () => context.go('/settings')), 156 162 ], 157 163 ), ··· 468 474 ); 469 475 } 470 476 477 + void _showOwnProfileMoreOptions(BuildContext context, ProfileViewDetailed profile) { 478 + showModalBottomSheet<void>( 479 + context: context, 480 + builder: (sheetContext) => SafeArea( 481 + child: Column( 482 + mainAxisSize: MainAxisSize.min, 483 + children: [ 484 + ListTile( 485 + leading: const Icon(Icons.hub_outlined), 486 + title: const Text('Profile Context'), 487 + onTap: () { 488 + Navigator.pop(sheetContext); 489 + context.push( 490 + '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', 491 + ); 492 + }, 493 + ), 494 + ], 495 + ), 496 + ), 497 + ); 498 + } 499 + 471 500 void _showProfileMoreOptions(BuildContext context, ProfileViewDetailed profile) { 472 501 showModalBottomSheet<void>( 473 502 context: context, ··· 501 530 onTap: () { 502 531 Navigator.pop(sheetContext); 503 532 _showAddToList(context, profile); 533 + }, 534 + ), 535 + ListTile( 536 + leading: const Icon(Icons.hub_outlined), 537 + title: const Text('Profile Context'), 538 + onTap: () { 539 + Navigator.pop(sheetContext); 540 + context.push( 541 + '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', 542 + ); 504 543 }, 505 544 ), 506 545 ],
+372
test/features/profile/cubit/profile_context_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/profile/cubit/profile_context_cubit.dart'; 7 + import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockProfileContextRepository extends Mock implements ProfileContextRepository {} 11 + 12 + const _did = 'did:plc:alice'; 13 + 14 + ProfileView _profile(String did) => ProfileView(did: did, handle: '$did.bsky.social', indexedAt: DateTime.utc(2026)); 15 + 16 + ListView _list(String rkey) => ListView( 17 + uri: AtUri.parse('at://did:plc:owner/app.bsky.graph.list/$rkey'), 18 + cid: 'cid-$rkey', 19 + creator: const ProfileView(did: 'did:plc:owner', handle: 'owner.bsky.social'), 20 + name: 'List $rkey', 21 + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsCuratelist), 22 + indexedAt: DateTime.utc(2026), 23 + ); 24 + 25 + void main() { 26 + late MockProfileContextRepository mockRepository; 27 + 28 + setUp(() { 29 + mockRepository = MockProfileContextRepository(); 30 + }); 31 + 32 + ProfileContextCubit buildCubit({bool isOwnProfile = false}) { 33 + return ProfileContextCubit(repository: mockRepository, did: _did, isOwnProfile: isOwnProfile); 34 + } 35 + 36 + group('ProfileContextCubit', () { 37 + group('initial state', () { 38 + test('has correct did and isOwnProfile', () { 39 + final cubit = buildCubit(isOwnProfile: true); 40 + expect(cubit.state.did, _did); 41 + expect(cubit.state.isOwnProfile, isTrue); 42 + expect(cubit.state.blockedByCount, 0); 43 + expect(cubit.state.blockingCount, 0); 44 + expect(cubit.state.listsOnCount, 0); 45 + expect(cubit.state.blockedByStatus, ProfileContextTabStatus.initial); 46 + expect(cubit.state.blockingStatus, ProfileContextTabStatus.initial); 47 + expect(cubit.state.listsOnStatus, ProfileContextTabStatus.initial); 48 + }); 49 + }); 50 + 51 + group('init', () { 52 + blocTest<ProfileContextCubit, ProfileContextState>( 53 + 'loads blockedByCount and listsOnCount in parallel', 54 + build: buildCubit, 55 + setUp: () { 56 + when(() => mockRepository.getBlockedByCount(_did)).thenAnswer((_) async => 42); 57 + when(() => mockRepository.getListsOnCount(_did)).thenAnswer((_) async => 7); 58 + }, 59 + act: (cubit) => cubit.init(), 60 + expect: () => [ 61 + predicate<ProfileContextState>((s) => s.blockedByCount == 42 && s.listsOnCount == 7 && s.blockingCount == 0), 62 + ], 63 + ); 64 + 65 + blocTest<ProfileContextCubit, ProfileContextState>( 66 + 'emits nothing when counts fail (best-effort)', 67 + build: buildCubit, 68 + setUp: () { 69 + when(() => mockRepository.getBlockedByCount(any())).thenThrow(Exception('network error')); 70 + when(() => mockRepository.getListsOnCount(any())).thenThrow(Exception('network error')); 71 + }, 72 + act: (cubit) => cubit.init(), 73 + expect: () => [], 74 + ); 75 + }); 76 + 77 + group('loadBlockedBy', () { 78 + final profile1 = _profile('did:plc:blocker1'); 79 + final profile2 = _profile('did:plc:blocker2'); 80 + 81 + blocTest<ProfileContextCubit, ProfileContextState>( 82 + 'emits loading then loaded with profiles on success', 83 + build: buildCubit, 84 + setUp: () { 85 + when( 86 + () => mockRepository.getBlockedByProfiles(_did, cursor: null), 87 + ).thenAnswer((_) async => (profiles: [profile1, profile2], cursor: 'next', total: 10)); 88 + }, 89 + act: (cubit) => cubit.loadBlockedBy(), 90 + expect: () => [ 91 + predicate<ProfileContextState>((s) => s.blockedByStatus == ProfileContextTabStatus.loading), 92 + predicate<ProfileContextState>( 93 + (s) => 94 + s.blockedByStatus == ProfileContextTabStatus.loaded && 95 + s.blockedByProfiles.length == 2 && 96 + s.blockedByCount == 10 && 97 + s.blockedByCursor == 'next' && 98 + s.blockedByHasMore, 99 + ), 100 + ], 101 + ); 102 + 103 + blocTest<ProfileContextCubit, ProfileContextState>( 104 + 'appends profiles on subsequent page load', 105 + build: buildCubit, 106 + seed: () => const ProfileContextState.initial(did: _did, isOwnProfile: false).copyWith( 107 + blockedByStatus: ProfileContextTabStatus.loaded, 108 + blockedByProfiles: [_profile('did:plc:first')], 109 + blockedByCursor: 'cursor-1', 110 + blockedByHasMore: true, 111 + ), 112 + setUp: () { 113 + when( 114 + () => mockRepository.getBlockedByProfiles(_did, cursor: 'cursor-1'), 115 + ).thenAnswer((_) async => (profiles: [profile1], cursor: null, total: 2)); 116 + }, 117 + act: (cubit) => cubit.loadBlockedBy(cursor: 'cursor-1'), 118 + expect: () => [ 119 + predicate<ProfileContextState>((s) => s.blockedByStatus == ProfileContextTabStatus.loading), 120 + predicate<ProfileContextState>( 121 + (s) => 122 + s.blockedByStatus == ProfileContextTabStatus.loaded && 123 + s.blockedByProfiles.length == 2 && 124 + s.blockedByCursor == null && 125 + !s.blockedByHasMore, 126 + ), 127 + ], 128 + ); 129 + 130 + blocTest<ProfileContextCubit, ProfileContextState>( 131 + 'emits error state on failure', 132 + build: buildCubit, 133 + setUp: () { 134 + when( 135 + () => mockRepository.getBlockedByProfiles(_did, cursor: any(named: 'cursor')), 136 + ).thenThrow(Exception('network error')); 137 + }, 138 + act: (cubit) => cubit.loadBlockedBy(), 139 + expect: () => [ 140 + predicate<ProfileContextState>((s) => s.blockedByStatus == ProfileContextTabStatus.loading), 141 + predicate<ProfileContextState>( 142 + (s) => 143 + s.blockedByStatus == ProfileContextTabStatus.error && 144 + s.blockedByError != null && 145 + s.blockedByProfiles.isEmpty, 146 + ), 147 + ], 148 + ); 149 + 150 + blocTest<ProfileContextCubit, ProfileContextState>( 151 + 'is a no-op when already loading', 152 + build: buildCubit, 153 + seed: () => const ProfileContextState.initial( 154 + did: _did, 155 + isOwnProfile: false, 156 + ).copyWith(blockedByStatus: ProfileContextTabStatus.loading), 157 + act: (cubit) => cubit.loadBlockedBy(), 158 + expect: () => [], 159 + ); 160 + }); 161 + 162 + group('loadBlocking', () { 163 + final profile1 = _profile('did:plc:blocked1'); 164 + 165 + blocTest<ProfileContextCubit, ProfileContextState>( 166 + 'emits loading then loaded with profiles on success for own profile', 167 + build: () => buildCubit(isOwnProfile: true), 168 + setUp: () { 169 + when( 170 + () => mockRepository.getBlockingProfiles(_did, cursor: null), 171 + ).thenAnswer((_) async => (profiles: [profile1], cursor: 'next', total: 1)); 172 + }, 173 + act: (cubit) => cubit.loadBlocking(), 174 + expect: () => [ 175 + predicate<ProfileContextState>((s) => s.blockingStatus == ProfileContextTabStatus.loading), 176 + predicate<ProfileContextState>( 177 + (s) => 178 + s.blockingStatus == ProfileContextTabStatus.loaded && 179 + s.blockingProfiles.length == 1 && 180 + s.blockingCount == 1 && 181 + s.blockingCursor == 'next' && 182 + s.blockingHasMore, 183 + ), 184 + ], 185 + ); 186 + 187 + blocTest<ProfileContextCubit, ProfileContextState>( 188 + 'is a no-op when viewing another profile (not own)', 189 + build: () => buildCubit(isOwnProfile: false), 190 + act: (cubit) => cubit.loadBlocking(), 191 + expect: () => [], 192 + ); 193 + 194 + blocTest<ProfileContextCubit, ProfileContextState>( 195 + 'accumulates blockingCount across pages', 196 + build: () => buildCubit(isOwnProfile: true), 197 + seed: () => const ProfileContextState.initial(did: _did, isOwnProfile: true).copyWith( 198 + blockingStatus: ProfileContextTabStatus.loaded, 199 + blockingProfiles: [_profile('did:plc:first')], 200 + blockingCursor: 'cursor-1', 201 + blockingHasMore: true, 202 + blockingCount: 1, 203 + ), 204 + setUp: () { 205 + when( 206 + () => mockRepository.getBlockingProfiles(_did, cursor: 'cursor-1'), 207 + ).thenAnswer((_) async => (profiles: [profile1], cursor: null, total: 1)); 208 + }, 209 + act: (cubit) => cubit.loadBlocking(cursor: 'cursor-1'), 210 + expect: () => [ 211 + predicate<ProfileContextState>((s) => s.blockingStatus == ProfileContextTabStatus.loading), 212 + predicate<ProfileContextState>((s) => s.blockingProfiles.length == 2 && s.blockingCount == 2), 213 + ], 214 + ); 215 + 216 + blocTest<ProfileContextCubit, ProfileContextState>( 217 + 'emits error state on failure', 218 + build: () => buildCubit(isOwnProfile: true), 219 + setUp: () { 220 + when( 221 + () => mockRepository.getBlockingProfiles(_did, cursor: any(named: 'cursor')), 222 + ).thenThrow(Exception('network error')); 223 + }, 224 + act: (cubit) => cubit.loadBlocking(), 225 + expect: () => [ 226 + predicate<ProfileContextState>((s) => s.blockingStatus == ProfileContextTabStatus.loading), 227 + predicate<ProfileContextState>( 228 + (s) => s.blockingStatus == ProfileContextTabStatus.error && s.blockingError != null, 229 + ), 230 + ], 231 + ); 232 + 233 + blocTest<ProfileContextCubit, ProfileContextState>( 234 + 'is a no-op when already loading', 235 + build: () => buildCubit(isOwnProfile: true), 236 + seed: () => const ProfileContextState.initial( 237 + did: _did, 238 + isOwnProfile: true, 239 + ).copyWith(blockingStatus: ProfileContextTabStatus.loading), 240 + act: (cubit) => cubit.loadBlocking(), 241 + expect: () => [], 242 + ); 243 + }); 244 + 245 + group('loadListsOn', () { 246 + final list1 = _list('list1'); 247 + final list2 = _list('list2'); 248 + 249 + blocTest<ProfileContextCubit, ProfileContextState>( 250 + 'emits loading then loaded with lists on success', 251 + build: buildCubit, 252 + setUp: () { 253 + when( 254 + () => mockRepository.getListsOn(_did, cursor: null), 255 + ).thenAnswer((_) async => (lists: [list1, list2], cursor: 'next', total: 5)); 256 + }, 257 + act: (cubit) => cubit.loadListsOn(), 258 + expect: () => [ 259 + predicate<ProfileContextState>((s) => s.listsOnStatus == ProfileContextTabStatus.loading), 260 + predicate<ProfileContextState>( 261 + (s) => 262 + s.listsOnStatus == ProfileContextTabStatus.loaded && 263 + s.listsOn.length == 2 && 264 + s.listsOnCount == 5 && 265 + s.listsOnCursor == 'next' && 266 + s.listsOnHasMore, 267 + ), 268 + ], 269 + ); 270 + 271 + blocTest<ProfileContextCubit, ProfileContextState>( 272 + 'appends lists on subsequent page load', 273 + build: buildCubit, 274 + seed: () => const ProfileContextState.initial(did: _did, isOwnProfile: false).copyWith( 275 + listsOnStatus: ProfileContextTabStatus.loaded, 276 + listsOn: [_list('existing')], 277 + listsOnCursor: 'cursor-1', 278 + listsOnHasMore: true, 279 + ), 280 + setUp: () { 281 + when( 282 + () => mockRepository.getListsOn(_did, cursor: 'cursor-1'), 283 + ).thenAnswer((_) async => (lists: [list1], cursor: null, total: 2)); 284 + }, 285 + act: (cubit) => cubit.loadListsOn(cursor: 'cursor-1'), 286 + expect: () => [ 287 + predicate<ProfileContextState>((s) => s.listsOnStatus == ProfileContextTabStatus.loading), 288 + predicate<ProfileContextState>( 289 + (s) => 290 + s.listsOnStatus == ProfileContextTabStatus.loaded && 291 + s.listsOn.length == 2 && 292 + s.listsOnCursor == null && 293 + !s.listsOnHasMore, 294 + ), 295 + ], 296 + ); 297 + 298 + blocTest<ProfileContextCubit, ProfileContextState>( 299 + 'emits error state on failure', 300 + build: buildCubit, 301 + setUp: () { 302 + when( 303 + () => mockRepository.getListsOn(_did, cursor: any(named: 'cursor')), 304 + ).thenThrow(Exception('network error')); 305 + }, 306 + act: (cubit) => cubit.loadListsOn(), 307 + expect: () => [ 308 + predicate<ProfileContextState>((s) => s.listsOnStatus == ProfileContextTabStatus.loading), 309 + predicate<ProfileContextState>( 310 + (s) => s.listsOnStatus == ProfileContextTabStatus.error && s.listsOnError != null, 311 + ), 312 + ], 313 + ); 314 + 315 + blocTest<ProfileContextCubit, ProfileContextState>( 316 + 'is a no-op when already loading', 317 + build: buildCubit, 318 + seed: () => const ProfileContextState.initial( 319 + did: _did, 320 + isOwnProfile: false, 321 + ).copyWith(listsOnStatus: ProfileContextTabStatus.loading), 322 + act: (cubit) => cubit.loadListsOn(), 323 + expect: () => [], 324 + ); 325 + }); 326 + 327 + group('ProfileContextState', () { 328 + test('copyWith preserves unchanged fields', () { 329 + final state = const ProfileContextState.initial( 330 + did: _did, 331 + isOwnProfile: true, 332 + ).copyWith(blockedByCount: 5, listsOnCount: 3); 333 + expect(state.blockedByCount, 5); 334 + expect(state.listsOnCount, 3); 335 + expect(state.blockingCount, 0); 336 + expect(state.did, _did); 337 + expect(state.isOwnProfile, isTrue); 338 + }); 339 + 340 + test('copyWith clears nullable cursor with explicit null', () { 341 + final state = const ProfileContextState.initial( 342 + did: _did, 343 + isOwnProfile: false, 344 + ).copyWith(blockedByCursor: 'cursor-1'); 345 + expect(state.blockedByCursor, 'cursor-1'); 346 + 347 + final cleared = state.copyWith(blockedByCursor: null); 348 + expect(cleared.blockedByCursor, isNull); 349 + }); 350 + 351 + test('copyWith clears nullable error with explicit null', () { 352 + final state = const ProfileContextState.initial( 353 + did: _did, 354 + isOwnProfile: false, 355 + ).copyWith(blockedByError: 'some error'); 356 + expect(state.blockedByError, 'some error'); 357 + 358 + final cleared = state.copyWith(blockedByError: null); 359 + expect(cleared.blockedByError, isNull); 360 + }); 361 + 362 + test('props includes all fields for equality', () { 363 + const s1 = ProfileContextState.initial(did: _did, isOwnProfile: false); 364 + const s2 = ProfileContextState.initial(did: _did, isOwnProfile: false); 365 + expect(s1, equals(s2)); 366 + 367 + final s3 = s1.copyWith(blockedByCount: 1); 368 + expect(s1, isNot(equals(s3))); 369 + }); 370 + }); 371 + }); 372 + }
-4
test/features/profile/data/profile_context_repository_test.dart
··· 9 9 import 'package:lazurite/core/network/constellation_client.dart'; 10 10 import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 11 11 12 - // --------------------------------------------------------------------------- 13 - // Helpers 14 - // --------------------------------------------------------------------------- 15 - 16 12 ProfileView _buildProfileView(String did, String handle) { 17 13 return ProfileView(did: did, handle: handle, indexedAt: DateTime.utc(2026, 1, 1)); 18 14 }
+357
test/features/profile/presentation/profile_context_screen_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' as bsky_graph; 5 + import 'package:flutter/material.dart'; 6 + import 'package:flutter_bloc/flutter_bloc.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:go_router/go_router.dart'; 9 + import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; 10 + import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 11 + import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 12 + import 'package:mocktail/mocktail.dart'; 13 + 14 + class MockProfileContextCubit extends MockCubit<ProfileContextState> implements ProfileContextCubit {} 15 + 16 + class MockProfileContextRepository extends Mock implements ProfileContextRepository {} 17 + 18 + const _did = 'did:plc:alice'; 19 + const _handle = 'alice.bsky.social'; 20 + 21 + ProfileView _profile(String did) => 22 + ProfileView(did: did, handle: '$did.bsky.social', displayName: 'User $did', indexedAt: DateTime.utc(2026)); 23 + 24 + bsky_graph.ListView _list(String rkey) => bsky_graph.ListView( 25 + uri: AtUri.parse('at://did:plc:owner/app.bsky.graph.list/$rkey'), 26 + cid: 'cid-$rkey', 27 + creator: const ProfileView(did: 'did:plc:owner', handle: 'owner.bsky.social'), 28 + name: 'List $rkey', 29 + purpose: const bsky_graph.ListPurpose.knownValue(data: bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist), 30 + indexedAt: DateTime.utc(2026), 31 + ); 32 + 33 + void main() { 34 + late MockProfileContextCubit cubit; 35 + 36 + ProfileContextState initialState({bool isOwnProfile = false}) => 37 + ProfileContextState.initial(did: _did, isOwnProfile: isOwnProfile); 38 + 39 + setUp(() { 40 + cubit = MockProfileContextCubit(); 41 + // Stub async methods to return completed futures by default. 42 + when(() => cubit.init()).thenAnswer((_) async {}); 43 + when(() => cubit.loadBlockedBy()).thenAnswer((_) async {}); 44 + when(() => cubit.loadBlockedBy(cursor: any(named: 'cursor'))).thenAnswer((_) async {}); 45 + when(() => cubit.loadBlocking()).thenAnswer((_) async {}); 46 + when(() => cubit.loadBlocking(cursor: any(named: 'cursor'))).thenAnswer((_) async {}); 47 + when(() => cubit.loadListsOn()).thenAnswer((_) async {}); 48 + when(() => cubit.loadListsOn(cursor: any(named: 'cursor'))).thenAnswer((_) async {}); 49 + when(() => cubit.refreshBlockedBy()).thenAnswer((_) async {}); 50 + when(() => cubit.refreshBlocking()).thenAnswer((_) async {}); 51 + when(() => cubit.refreshListsOn()).thenAnswer((_) async {}); 52 + }); 53 + 54 + Widget buildSubject({ 55 + required ProfileContextState state, 56 + List<NavigatorObserver> observers = const [], 57 + String? Function(BuildContext, GoRouterState)? redirect, 58 + }) { 59 + when(() => cubit.state).thenReturn(state); 60 + whenListen(cubit, const Stream<ProfileContextState>.empty(), initialState: state); 61 + 62 + final router = GoRouter( 63 + observers: observers, 64 + routes: [ 65 + GoRoute( 66 + path: '/', 67 + builder: (context, _) => BlocProvider<ProfileContextCubit>.value( 68 + value: cubit, 69 + child: const ProfileContextScreen(handle: _handle), 70 + ), 71 + routes: [ 72 + GoRoute( 73 + path: 'profile/view', 74 + builder: (context, state) => const Scaffold(body: Text('Profile View')), 75 + ), 76 + GoRoute( 77 + path: 'list', 78 + builder: (context, state) => const Scaffold(body: Text('List Detail')), 79 + ), 80 + ], 81 + ), 82 + ], 83 + ); 84 + 85 + return MaterialApp.router(routerConfig: router); 86 + } 87 + 88 + group('ProfileContextScreen', () { 89 + testWidgets('renders 3 tabs', (tester) async { 90 + await tester.pumpWidget(buildSubject(state: initialState())); 91 + 92 + expect(find.text('Blocked By'), findsOneWidget); 93 + expect(find.text('Blocking'), findsOneWidget); 94 + expect(find.text('Lists'), findsOneWidget); 95 + }); 96 + 97 + testWidgets('AppBar shows title and handle subtitle', (tester) async { 98 + await tester.pumpWidget(buildSubject(state: initialState())); 99 + 100 + expect(find.text('Profile Context'), findsOneWidget); 101 + expect(find.text('@$_handle'), findsOneWidget); 102 + }); 103 + 104 + testWidgets('tab labels include counts when non-zero', (tester) async { 105 + final state = const ProfileContextState.initial( 106 + did: _did, 107 + isOwnProfile: false, 108 + ).copyWith(blockedByCount: 5, listsOnCount: 3); 109 + await tester.pumpWidget(buildSubject(state: state)); 110 + 111 + expect(find.text('Blocked By (5)'), findsOneWidget); 112 + expect(find.text('Lists (3)'), findsOneWidget); 113 + }); 114 + 115 + group('Blocked By tab', () { 116 + testWidgets('shows count header on initial state', (tester) async { 117 + final state = initialState().copyWith(blockedByCount: 10); 118 + await tester.pumpWidget(buildSubject(state: state)); 119 + 120 + expect(find.text('10 accounts'), findsOneWidget); 121 + }); 122 + 123 + testWidgets('shows Show accounts button when status is initial', (tester) async { 124 + await tester.pumpWidget(buildSubject(state: initialState())); 125 + 126 + expect(find.byKey(const Key('blocked_by_show_accounts')), findsOneWidget); 127 + }); 128 + 129 + testWidgets('Show accounts button calls loadBlockedBy on cubit', (tester) async { 130 + await tester.pumpWidget(buildSubject(state: initialState())); 131 + 132 + await tester.tap(find.byKey(const Key('blocked_by_show_accounts'))); 133 + verify(() => cubit.loadBlockedBy()).called(1); 134 + }); 135 + 136 + testWidgets('renders profile tiles when loaded', (tester) async { 137 + final profiles = [_profile('did:plc:user1'), _profile('did:plc:user2')]; 138 + final state = initialState().copyWith( 139 + blockedByStatus: ProfileContextTabStatus.loaded, 140 + blockedByProfiles: profiles, 141 + ); 142 + await tester.pumpWidget(buildSubject(state: state)); 143 + 144 + expect(find.text('User did:plc:user1'), findsOneWidget); 145 + expect(find.text('User did:plc:user2'), findsOneWidget); 146 + }); 147 + 148 + testWidgets('profile tile navigates to /profile/view on tap', (tester) async { 149 + final profiles = [_profile('did:plc:user1')]; 150 + final state = initialState().copyWith( 151 + blockedByStatus: ProfileContextTabStatus.loaded, 152 + blockedByProfiles: profiles, 153 + ); 154 + 155 + String? pushedRoute; 156 + final observer = _TestNavigatorObserver(onPush: (route, _) => pushedRoute = route.settings.name); 157 + 158 + await tester.pumpWidget(buildSubject(state: state, observers: [observer])); 159 + await tester.tap(find.text('User did:plc:user1')); 160 + await tester.pumpAndSettle(); 161 + 162 + expect(pushedRoute, '/profile/view'); 163 + expect(find.text('Profile View'), findsOneWidget); 164 + }); 165 + 166 + testWidgets('shows contextualizing note', (tester) async { 167 + await tester.pumpWidget(buildSubject(state: initialState())); 168 + 169 + expect(find.textContaining('Blocks are a normal part of social media'), findsOneWidget); 170 + }); 171 + 172 + testWidgets('shows empty state when loaded with no profiles', (tester) async { 173 + final state = initialState().copyWith(blockedByStatus: ProfileContextTabStatus.loaded, blockedByProfiles: []); 174 + await tester.pumpWidget(buildSubject(state: state)); 175 + 176 + expect(find.text('No accounts have blocked this user'), findsOneWidget); 177 + }); 178 + 179 + testWidgets('shows error and retry button on error state', (tester) async { 180 + final state = initialState().copyWith( 181 + blockedByStatus: ProfileContextTabStatus.error, 182 + blockedByError: 'Something went wrong', 183 + ); 184 + await tester.pumpWidget(buildSubject(state: state)); 185 + 186 + expect(find.text('Something went wrong'), findsOneWidget); 187 + expect(find.text('Retry'), findsOneWidget); 188 + }); 189 + 190 + testWidgets('retry calls loadBlockedBy on cubit', (tester) async { 191 + final state = initialState().copyWith(blockedByStatus: ProfileContextTabStatus.error, blockedByError: 'error'); 192 + await tester.pumpWidget(buildSubject(state: state)); 193 + 194 + await tester.tap(find.text('Retry')); 195 + verify(() => cubit.loadBlockedBy()).called(1); 196 + }); 197 + }); 198 + 199 + group('Blocking tab', () { 200 + testWidgets('shows explanatory text for non-own profile', (tester) async { 201 + final state = initialState(isOwnProfile: false); 202 + await tester.pumpWidget(buildSubject(state: state)); 203 + 204 + await tester.tap(find.text('Blocking')); 205 + await tester.pumpAndSettle(); 206 + 207 + expect(find.textContaining('only available when viewing your own profile'), findsOneWidget); 208 + }); 209 + 210 + testWidgets('renders profile tiles for own profile when loaded', (tester) async { 211 + final profiles = [_profile('did:plc:blocked1')]; 212 + final state = const ProfileContextState.initial( 213 + did: _did, 214 + isOwnProfile: true, 215 + ).copyWith(blockingStatus: ProfileContextTabStatus.loaded, blockingProfiles: profiles); 216 + await tester.pumpWidget(buildSubject(state: state)); 217 + 218 + await tester.tap(find.text('Blocking')); 219 + await tester.pumpAndSettle(); 220 + 221 + expect(find.text('User did:plc:blocked1'), findsOneWidget); 222 + }); 223 + 224 + testWidgets('shows empty state for own profile when loaded with no profiles', (tester) async { 225 + final state = const ProfileContextState.initial( 226 + did: _did, 227 + isOwnProfile: true, 228 + ).copyWith(blockingStatus: ProfileContextTabStatus.loaded, blockingProfiles: []); 229 + await tester.pumpWidget(buildSubject(state: state)); 230 + 231 + await tester.tap(find.text('Blocking')); 232 + await tester.pumpAndSettle(); 233 + 234 + expect(find.text('Not blocking anyone'), findsOneWidget); 235 + }); 236 + 237 + testWidgets('shows error and retry on error state for own profile', (tester) async { 238 + final state = const ProfileContextState.initial( 239 + did: _did, 240 + isOwnProfile: true, 241 + ).copyWith(blockingStatus: ProfileContextTabStatus.error, blockingError: 'Block error'); 242 + await tester.pumpWidget(buildSubject(state: state)); 243 + 244 + await tester.tap(find.text('Blocking')); 245 + await tester.pumpAndSettle(); 246 + 247 + expect(find.text('Block error'), findsOneWidget); 248 + expect(find.text('Retry'), findsOneWidget); 249 + }); 250 + 251 + testWidgets('retry calls loadBlocking on cubit', (tester) async { 252 + final state = const ProfileContextState.initial( 253 + did: _did, 254 + isOwnProfile: true, 255 + ).copyWith(blockingStatus: ProfileContextTabStatus.error, blockingError: 'error'); 256 + await tester.pumpWidget(buildSubject(state: state)); 257 + 258 + await tester.tap(find.text('Blocking')); 259 + await tester.pumpAndSettle(); 260 + 261 + await tester.tap(find.text('Retry')); 262 + verify(() => cubit.loadBlocking()).called(greaterThanOrEqualTo(1)); 263 + }); 264 + 265 + testWidgets('profile tile navigates to /profile/view on tap', (tester) async { 266 + final profiles = [_profile('did:plc:blocked1')]; 267 + final state = const ProfileContextState.initial( 268 + did: _did, 269 + isOwnProfile: true, 270 + ).copyWith(blockingStatus: ProfileContextTabStatus.loaded, blockingProfiles: profiles); 271 + await tester.pumpWidget(buildSubject(state: state)); 272 + 273 + await tester.tap(find.text('Blocking')); 274 + await tester.pumpAndSettle(); 275 + 276 + await tester.tap(find.text('User did:plc:blocked1')); 277 + await tester.pumpAndSettle(); 278 + 279 + expect(find.text('Profile View'), findsOneWidget); 280 + }); 281 + }); 282 + 283 + group('Lists tab', () { 284 + testWidgets('renders list cards when loaded', (tester) async { 285 + final lists = [_list('rkey1'), _list('rkey2')]; 286 + final state = initialState().copyWith(listsOnStatus: ProfileContextTabStatus.loaded, listsOn: lists); 287 + await tester.pumpWidget(buildSubject(state: state)); 288 + 289 + await tester.tap(find.text('Lists')); 290 + await tester.pumpAndSettle(); 291 + 292 + expect(find.text('List rkey1'), findsOneWidget); 293 + expect(find.text('List rkey2'), findsOneWidget); 294 + }); 295 + 296 + testWidgets('list card navigates to /list on tap', (tester) async { 297 + final lists = [_list('rkey1')]; 298 + final state = initialState().copyWith(listsOnStatus: ProfileContextTabStatus.loaded, listsOn: lists); 299 + await tester.pumpWidget(buildSubject(state: state)); 300 + 301 + await tester.tap(find.text('Lists')); 302 + await tester.pumpAndSettle(); 303 + 304 + await tester.tap(find.text('List rkey1')); 305 + await tester.pumpAndSettle(); 306 + 307 + expect(find.text('List Detail'), findsOneWidget); 308 + }); 309 + 310 + testWidgets('shows empty state when loaded with no lists', (tester) async { 311 + final state = initialState().copyWith(listsOnStatus: ProfileContextTabStatus.loaded, listsOn: []); 312 + await tester.pumpWidget(buildSubject(state: state)); 313 + 314 + await tester.tap(find.text('Lists')); 315 + await tester.pumpAndSettle(); 316 + 317 + expect(find.text('Not on any lists'), findsOneWidget); 318 + }); 319 + 320 + testWidgets('shows error and retry on error state', (tester) async { 321 + final state = initialState().copyWith( 322 + listsOnStatus: ProfileContextTabStatus.error, 323 + listsOnError: 'List load error', 324 + ); 325 + await tester.pumpWidget(buildSubject(state: state)); 326 + 327 + await tester.tap(find.text('Lists')); 328 + await tester.pumpAndSettle(); 329 + 330 + expect(find.text('List load error'), findsOneWidget); 331 + expect(find.text('Retry'), findsOneWidget); 332 + }); 333 + 334 + testWidgets('retry calls loadListsOn on cubit', (tester) async { 335 + final state = initialState().copyWith(listsOnStatus: ProfileContextTabStatus.error, listsOnError: 'error'); 336 + await tester.pumpWidget(buildSubject(state: state)); 337 + 338 + await tester.tap(find.text('Lists')); 339 + await tester.pumpAndSettle(); 340 + 341 + await tester.tap(find.text('Retry')); 342 + verify(() => cubit.loadListsOn()).called(greaterThanOrEqualTo(1)); 343 + }); 344 + }); 345 + }); 346 + } 347 + 348 + class _TestNavigatorObserver extends NavigatorObserver { 349 + _TestNavigatorObserver({this.onPush}); 350 + 351 + final void Function(Route<dynamic>, Route<dynamic>?)? onPush; 352 + 353 + @override 354 + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { 355 + onPush?.call(route, previousRoute); 356 + } 357 + }