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: user lists with creation, detail, and membership management

+2338 -30
+8 -6
docs/tasks/phase-4.md
··· 38 38 39 39 ## M18 — Lists 40 40 41 + Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) 42 + 41 43 ### Core 42 44 43 45 - [x] `ListBloc` — events: `ListRequested`, `ListRefreshed`, `ListItemAdded`, `ListItemRemoved`, `ListMuted`, `ListUnmuted`, `ListBlocked`, `ListUnblocked` ··· 59 61 60 62 ### Screens 61 63 62 - - [ ] My Lists screen — curation and moderation tabs, FAB to create new list 63 - - [ ] List detail screen — header (name, avatar, description, creator, member count), Feed tab (curation lists), Members tab 64 - - [ ] Add/remove members screen — search field + current members with remove buttons 65 - - [ ] Create/edit list dialog — name, description, avatar picker, purpose selector 64 + - [x] My Lists screen — curation and moderation tabs, FAB to create new list 65 + - [x] List detail screen — header (name, avatar, description, creator, member count), Feed tab (curation lists), Members tab 66 + - [x] Add/remove members screen — search field + current members with remove buttons 67 + - [x] Create/edit list dialog — name, description, avatar picker, purpose selector 66 68 67 69 ### Profile Integration 68 70 69 - - [ ] "Lists" tab on profile screens via `getLists` 70 - - [ ] "Add to list" option in profile overflow menu using `getListsWithMembership` 71 + - [x] "Lists" tab on profile screens via `getLists` 72 + - [x] "Add to list" option in profile overflow menu using `getListsWithMembership` 71 73 72 74 ## M19 — Starter Packs 73 75
+29
lib/core/router/app_router.dart
··· 1 1 import 'dart:async'; 2 2 3 + import 'package:atproto_core/atproto_core.dart' show AtUri; 3 4 import 'package:flutter/material.dart'; 4 5 import 'package:go_router/go_router.dart'; 5 6 import 'package:bluesky/bluesky.dart'; ··· 32 33 import 'package:lazurite/features/messages/data/convo_repository.dart'; 33 34 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 34 35 import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; 36 + import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 37 + import 'package:lazurite/features/lists/data/list_repository.dart'; 38 + import 'package:lazurite/features/lists/presentation/list_detail_screen.dart'; 39 + import 'package:lazurite/features/lists/presentation/list_members_screen.dart'; 40 + import 'package:lazurite/features/lists/presentation/my_lists_screen.dart'; 35 41 import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; 36 42 import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; 37 43 import 'package:lazurite/features/settings/presentation/about_screen.dart'; ··· 122 128 GoRoute( 123 129 path: '/saved', 124 130 builder: (context, state) => SavedPostsScreen(accountDid: context.read<String>()), 131 + ), 132 + GoRoute(path: '/lists', builder: (context, state) => const MyListsScreen()), 133 + GoRoute( 134 + path: '/list', 135 + builder: (context, state) { 136 + final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); 137 + final listUri = AtUri.parse(uriStr); 138 + return ListDetailScreen(listUri: listUri); 139 + }, 140 + routes: [ 141 + GoRoute( 142 + path: 'members', 143 + builder: (context, state) { 144 + final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); 145 + final listUri = AtUri.parse(uriStr); 146 + return BlocProvider( 147 + create: (_) => 148 + ListBloc(listRepository: context.read<ListRepository>())..add(ListRequested(listUri: listUri)), 149 + child: ListMembersScreen(listUri: listUri), 150 + ); 151 + }, 152 + ), 153 + ], 125 154 ), 126 155 StatefulShellRoute.indexedStack( 127 156 builder: (context, state, navigationShell) {
+31
lib/core/widgets/sliver_tab_bar_delegate.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:flutter/material.dart'; 4 + 5 + /// A [SliverPersistentHeaderDelegate] that pins a [TabBar] with a frosted-glass 6 + /// background. Extracted so it can be shared across multiple screens. 7 + class SliverTabBarDelegate extends SliverPersistentHeaderDelegate { 8 + SliverTabBarDelegate(this.tabBar); 9 + 10 + final TabBar tabBar; 11 + 12 + @override 13 + double get minExtent => tabBar.preferredSize.height; 14 + 15 + @override 16 + double get maxExtent => tabBar.preferredSize.height; 17 + 18 + @override 19 + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { 20 + final colorScheme = Theme.of(context).colorScheme; 21 + return ClipRect( 22 + child: BackdropFilter( 23 + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), 24 + child: ColoredBox(color: colorScheme.surface.withValues(alpha: 0.85), child: tabBar), 25 + ), 26 + ); 27 + } 28 + 29 + @override 30 + bool shouldRebuild(SliverTabBarDelegate oldDelegate) => false; 31 + }
+46
lib/features/lists/cubit/add_to_list_cubit.dart
··· 1 + import 'package:bluesky/app_bsky_graph_getlistswithmembership.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 'add_to_list_state.dart'; 7 + 8 + class AddToListCubit extends Cubit<AddToListState> { 9 + AddToListCubit({required ListRepository listRepository, required String currentUserDid}) 10 + : _listRepository = listRepository, 11 + super(const AddToListState.initial()); 12 + 13 + final ListRepository _listRepository; 14 + 15 + Future<void> load({required String targetDid}) async { 16 + emit(AddToListState.loading(targetDid: targetDid)); 17 + 18 + try { 19 + final result = await _listRepository.getListsWithMembership(actor: targetDid); 20 + emit(AddToListState.loaded(targetDid: targetDid, lists: result.lists)); 21 + } catch (error) { 22 + emit(AddToListState.error(message: 'Failed to load lists: $error', targetDid: targetDid)); 23 + } 24 + } 25 + 26 + Future<void> toggleMembership(ListWithMembership entry) async { 27 + if (state.status != AddToListStatus.loaded) return; 28 + if (state.togglingUris.contains(entry.list.uri.toString())) return; 29 + 30 + emit(state.copyWith(togglingUris: {...state.togglingUris, entry.list.uri.toString()})); 31 + 32 + try { 33 + if (entry.listItem != null) { 34 + await _listRepository.removeListItem(listItemUri: entry.listItem!.uri); 35 + } else { 36 + await _listRepository.addListItem(listUri: entry.list.uri, subjectDid: state.targetDid!); 37 + } 38 + 39 + final result = await _listRepository.getListsWithMembership(actor: state.targetDid!); 40 + emit(AddToListState.loaded(targetDid: state.targetDid!, lists: result.lists)); 41 + } catch (_) { 42 + final newTogglingUris = {...state.togglingUris}..remove(entry.list.uri.toString()); 43 + emit(state.copyWith(togglingUris: newTogglingUris)); 44 + } 45 + } 46 + }
+49
lib/features/lists/cubit/add_to_list_state.dart
··· 1 + part of 'add_to_list_cubit.dart'; 2 + 3 + enum AddToListStatus { initial, loading, loaded, error } 4 + 5 + class AddToListState extends Equatable { 6 + const AddToListState._({ 7 + required this.status, 8 + this.targetDid, 9 + this.lists = const [], 10 + this.togglingUris = const {}, 11 + this.errorMessage, 12 + }); 13 + 14 + const AddToListState.initial() : this._(status: AddToListStatus.initial); 15 + 16 + const AddToListState.loading({required String targetDid}) 17 + : this._(status: AddToListStatus.loading, targetDid: targetDid); 18 + 19 + const AddToListState.loaded({required String targetDid, required List<ListWithMembership> lists}) 20 + : this._(status: AddToListStatus.loaded, targetDid: targetDid, lists: lists); 21 + 22 + const AddToListState.error({required String message, String? targetDid}) 23 + : this._(status: AddToListStatus.error, targetDid: targetDid, errorMessage: message); 24 + 25 + final AddToListStatus status; 26 + final String? targetDid; 27 + final List<ListWithMembership> lists; 28 + final Set<String> togglingUris; 29 + final String? errorMessage; 30 + 31 + AddToListState copyWith({ 32 + AddToListStatus? status, 33 + String? targetDid, 34 + List<ListWithMembership>? lists, 35 + Set<String>? togglingUris, 36 + String? errorMessage, 37 + }) { 38 + return AddToListState._( 39 + status: status ?? this.status, 40 + targetDid: targetDid ?? this.targetDid, 41 + lists: lists ?? this.lists, 42 + togglingUris: togglingUris ?? this.togglingUris, 43 + errorMessage: errorMessage ?? this.errorMessage, 44 + ); 45 + } 46 + 47 + @override 48 + List<Object?> get props => [status, targetDid, lists, togglingUris, errorMessage]; 49 + }
+440
lib/features/lists/presentation/list_detail_screen.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 3 + import 'package:bluesky/moderation.dart' as bsky_moderation; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; 8 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 10 + import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 11 + import 'package:lazurite/features/lists/bloc/list_feed_bloc.dart'; 12 + import 'package:lazurite/features/lists/data/list_repository.dart'; 13 + import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart'; 14 + 15 + class ListDetailScreen extends StatelessWidget { 16 + const ListDetailScreen({super.key, required this.listUri}); 17 + 18 + final AtUri listUri; 19 + 20 + @override 21 + Widget build(BuildContext context) { 22 + final repository = context.read<ListRepository>(); 23 + return MultiBlocProvider( 24 + providers: [ 25 + BlocProvider( 26 + create: (_) => ListBloc(listRepository: repository)..add(ListRequested(listUri: listUri)), 27 + ), 28 + BlocProvider( 29 + create: (_) => ListFeedBloc(listRepository: repository)..add(ListFeedRequested(listUri: listUri)), 30 + ), 31 + ], 32 + child: const _ListDetailView(), 33 + ); 34 + } 35 + } 36 + 37 + class _ListDetailView extends StatefulWidget { 38 + const _ListDetailView(); 39 + 40 + @override 41 + State<_ListDetailView> createState() => _ListDetailViewState(); 42 + } 43 + 44 + class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProviderStateMixin { 45 + late final TabController _tabController; 46 + 47 + @override 48 + void initState() { 49 + super.initState(); 50 + _tabController = TabController(length: 2, vsync: this); 51 + } 52 + 53 + @override 54 + void dispose() { 55 + _tabController.dispose(); 56 + super.dispose(); 57 + } 58 + 59 + bool _isCurationList(bsky_graph.ListView? list) { 60 + return list?.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist; 61 + } 62 + 63 + bool _isOwnList(BuildContext context, bsky_graph.ListView? list) { 64 + final did = context.read<AuthBloc>().state.tokens?.did; 65 + return did != null && list?.creator.did == did; 66 + } 67 + 68 + Future<void> _showEditDialog(BuildContext context, bsky_graph.ListView list) async { 69 + final result = await showDialog<CreateEditListResult>( 70 + context: context, 71 + builder: (_) => CreateEditListDialog( 72 + initialName: list.name, 73 + initialDescription: list.description, 74 + initialAvatarUrl: list.avatar, 75 + fixedPurpose: list.purpose.toJson(), 76 + ), 77 + ); 78 + 79 + if (result == null || !context.mounted) return; 80 + 81 + final userDid = context.read<AuthBloc>().state.tokens?.did; 82 + if (userDid == null) return; 83 + 84 + context.read<ListBloc>().add( 85 + ListUpdated( 86 + userDid: userDid, 87 + name: result.name, 88 + description: result.description, 89 + avatarBytes: result.avatarBytes, 90 + avatarMimeType: result.avatarMimeType, 91 + ), 92 + ); 93 + } 94 + 95 + Future<void> _confirmDelete(BuildContext context) async { 96 + final confirmed = await showDialog<bool>( 97 + context: context, 98 + builder: (dialogContext) => AlertDialog( 99 + title: const Text('Delete list?'), 100 + content: const Text('This action cannot be undone.'), 101 + actions: [ 102 + TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), 103 + FilledButton( 104 + style: FilledButton.styleFrom( 105 + backgroundColor: Theme.of(dialogContext).colorScheme.error, 106 + foregroundColor: Theme.of(dialogContext).colorScheme.onError, 107 + ), 108 + onPressed: () => Navigator.pop(dialogContext, true), 109 + child: const Text('Delete'), 110 + ), 111 + ], 112 + ), 113 + ); 114 + 115 + if (confirmed == true && context.mounted) { 116 + final userDid = context.read<AuthBloc>().state.tokens?.did; 117 + if (userDid != null) { 118 + context.read<ListBloc>().add(ListDeleted(userDid: userDid)); 119 + } 120 + } 121 + } 122 + 123 + void _showMoreOptions(BuildContext context, ListState state) { 124 + final list = state.list; 125 + if (list == null) return; 126 + 127 + final isMuted = list.viewer?.isMuted ?? false; 128 + final isBlocked = list.viewer?.hasBlocked ?? false; 129 + final isOwn = _isOwnList(context, list); 130 + 131 + showModalBottomSheet<void>( 132 + context: context, 133 + builder: (sheetContext) => SafeArea( 134 + child: Column( 135 + mainAxisSize: MainAxisSize.min, 136 + children: [ 137 + if (isOwn) ...[ 138 + ListTile( 139 + leading: const Icon(Icons.edit_outlined), 140 + title: const Text('Edit list'), 141 + onTap: () { 142 + Navigator.pop(sheetContext); 143 + _showEditDialog(context, list); 144 + }, 145 + ), 146 + ListTile( 147 + leading: const Icon(Icons.person_add_outlined), 148 + title: const Text('Add members'), 149 + onTap: () async { 150 + final listUriStr = Uri.encodeComponent(list.uri.toString()); 151 + Navigator.pop(sheetContext); 152 + await context.push('/list/members?uri=$listUriStr'); 153 + if (context.mounted) { 154 + context.read<ListBloc>().add(const ListRefreshed()); 155 + } 156 + }, 157 + ), 158 + ListTile( 159 + leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 160 + title: Text('Delete list', style: TextStyle(color: Theme.of(context).colorScheme.error)), 161 + onTap: () { 162 + Navigator.pop(sheetContext); 163 + _confirmDelete(context); 164 + }, 165 + ), 166 + ], 167 + ListTile( 168 + leading: Icon(isMuted ? Icons.volume_up_outlined : Icons.volume_off_outlined), 169 + title: Text(isMuted ? 'Unmute list' : 'Mute list'), 170 + onTap: () { 171 + Navigator.pop(sheetContext); 172 + context.read<ListBloc>().add(isMuted ? const ListUnmuted() : const ListMuted()); 173 + }, 174 + ), 175 + if (list.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist) 176 + ListTile( 177 + leading: Icon(isBlocked ? Icons.block_flipped : Icons.block_outlined), 178 + title: Text(isBlocked ? 'Unblock via list' : 'Block via list'), 179 + onTap: () { 180 + Navigator.pop(sheetContext); 181 + context.read<ListBloc>().add(isBlocked ? const ListUnblocked() : const ListBlocked()); 182 + }, 183 + ), 184 + ], 185 + ), 186 + ), 187 + ); 188 + } 189 + 190 + @override 191 + Widget build(BuildContext context) { 192 + return BlocListener<ListBloc, ListState>( 193 + listenWhen: (prev, curr) => curr.status == ListStatus.deleted, 194 + listener: (context, state) { 195 + if (context.canPop()) { 196 + context.pop(); 197 + } else { 198 + context.go('/profile'); 199 + } 200 + }, 201 + child: BlocBuilder<ListBloc, ListState>( 202 + builder: (context, state) { 203 + final list = state.list; 204 + final isCuration = _isCurationList(list); 205 + 206 + return Scaffold( 207 + body: NestedScrollView( 208 + headerSliverBuilder: (context, innerBoxIsScrolled) { 209 + return [ 210 + SliverAppBar( 211 + floating: true, 212 + pinned: true, 213 + snap: true, 214 + title: Text(list?.name ?? 'List'), 215 + actions: [ 216 + if (state.isMutating) 217 + const Padding( 218 + padding: EdgeInsets.symmetric(horizontal: 16), 219 + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), 220 + ) 221 + else 222 + IconButton( 223 + icon: const Icon(Icons.more_vert), 224 + onPressed: state.status == ListStatus.loaded ? () => _showMoreOptions(context, state) : null, 225 + ), 226 + ], 227 + ), 228 + if (list != null) 229 + SliverToBoxAdapter(child: _buildHeader(context, list)) 230 + else if (state.isLoading) 231 + const SliverToBoxAdapter( 232 + child: Padding( 233 + padding: EdgeInsets.all(24), 234 + child: Center(child: CircularProgressIndicator()), 235 + ), 236 + ) 237 + else if (state.hasError) 238 + SliverToBoxAdapter( 239 + child: Padding( 240 + padding: const EdgeInsets.all(24), 241 + child: Center(child: Text(state.errorMessage ?? 'Failed to load list')), 242 + ), 243 + ), 244 + SliverPersistentHeader( 245 + pinned: true, 246 + delegate: SliverTabBarDelegate( 247 + TabBar( 248 + controller: _tabController, 249 + tabs: const [ 250 + Tab(text: 'FEED'), 251 + Tab(text: 'MEMBERS'), 252 + ], 253 + labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 254 + unselectedLabelStyle: const TextStyle( 255 + fontSize: 11, 256 + fontWeight: FontWeight.w700, 257 + letterSpacing: 2.2, 258 + ), 259 + indicatorWeight: 2, 260 + ), 261 + ), 262 + ), 263 + ]; 264 + }, 265 + body: TabBarView( 266 + controller: _tabController, 267 + children: [ 268 + isCuration ? _buildFeedTab(context) : _buildFeedUnavailableTab(), 269 + _buildMembersTab(context, state), 270 + ], 271 + ), 272 + ), 273 + ); 274 + }, 275 + ), 276 + ); 277 + } 278 + 279 + Widget _buildHeader(BuildContext context, bsky_graph.ListView list) { 280 + final colorScheme = Theme.of(context).colorScheme; 281 + final textTheme = Theme.of(context).textTheme; 282 + 283 + return Padding( 284 + padding: const EdgeInsets.all(16), 285 + child: Column( 286 + crossAxisAlignment: CrossAxisAlignment.start, 287 + children: [ 288 + Row( 289 + children: [ 290 + CircleAvatar( 291 + radius: 32, 292 + backgroundColor: colorScheme.surfaceContainerHighest, 293 + backgroundImage: list.avatar != null ? NetworkImage(list.avatar!) : null, 294 + child: list.avatar == null ? Icon(Icons.list, color: colorScheme.onSurfaceVariant) : null, 295 + ), 296 + const SizedBox(width: 16), 297 + Expanded( 298 + child: Column( 299 + crossAxisAlignment: CrossAxisAlignment.start, 300 + children: [ 301 + Text(list.name, style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700)), 302 + const SizedBox(height: 2), 303 + Text( 304 + 'by @${list.creator.handle}', 305 + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 306 + ), 307 + const SizedBox(height: 4), 308 + Text( 309 + '${list.listItemCount ?? 0} members', 310 + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 311 + ), 312 + ], 313 + ), 314 + ), 315 + ], 316 + ), 317 + if (list.description?.isNotEmpty ?? false) ...[ 318 + const SizedBox(height: 12), 319 + Text(list.description!, style: textTheme.bodyMedium), 320 + ], 321 + ], 322 + ), 323 + ); 324 + } 325 + 326 + Widget _buildFeedTab(BuildContext context) { 327 + return BlocBuilder<ListFeedBloc, ListFeedState>( 328 + builder: (context, feedState) { 329 + if (feedState.isLoading) { 330 + return const Center(child: CircularProgressIndicator()); 331 + } 332 + 333 + if (feedState.hasError) { 334 + return Center(child: Text(feedState.errorMessage ?? 'Failed to load feed')); 335 + } 336 + 337 + if (!feedState.hasPosts) { 338 + return const Center(child: Text('No posts yet')); 339 + } 340 + 341 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 342 + 343 + return RefreshIndicator( 344 + onRefresh: () async => context.read<ListFeedBloc>().add(const ListFeedRefreshed()), 345 + child: NotificationListener<ScrollNotification>( 346 + onNotification: (notification) { 347 + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 && 348 + feedState.hasMore && 349 + !feedState.isLoadingMore) { 350 + context.read<ListFeedBloc>().add(const ListFeedLoadMoreRequested()); 351 + } 352 + return false; 353 + }, 354 + child: ListView.builder( 355 + itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 356 + itemBuilder: (context, index) { 357 + if (index >= feedState.posts.length) { 358 + return const Padding( 359 + padding: EdgeInsets.all(16), 360 + child: Center(child: CircularProgressIndicator()), 361 + ); 362 + } 363 + return PostCardWithActions( 364 + feedViewPost: feedState.posts[index], 365 + accountDid: accountDid, 366 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 367 + ); 368 + }, 369 + ), 370 + ), 371 + ); 372 + }, 373 + ); 374 + } 375 + 376 + Widget _buildFeedUnavailableTab() { 377 + return const Center(child: Text('Feed not available for moderation lists')); 378 + } 379 + 380 + Widget _buildMembersTab(BuildContext context, ListState state) { 381 + if (state.isLoading) { 382 + return const Center(child: CircularProgressIndicator()); 383 + } 384 + 385 + if (state.hasError && !state.hasItems) { 386 + return Center(child: Text(state.errorMessage ?? 'Failed to load members')); 387 + } 388 + 389 + if (!state.hasItems) { 390 + return const Center(child: Text('No members yet')); 391 + } 392 + 393 + final isOwn = _isOwnList(context, state.list); 394 + 395 + return RefreshIndicator( 396 + onRefresh: () async => context.read<ListBloc>().add(const ListRefreshed()), 397 + child: ListView.builder( 398 + itemCount: state.items.length + (state.isRefreshing ? 1 : 0), 399 + itemBuilder: (context, index) { 400 + if (index >= state.items.length) { 401 + return const Padding( 402 + padding: EdgeInsets.all(16), 403 + child: Center(child: CircularProgressIndicator()), 404 + ); 405 + } 406 + 407 + final item = state.items[index]; 408 + final subject = item.subject; 409 + 410 + return ListTile( 411 + key: ValueKey(item.uri), 412 + leading: CircleAvatar( 413 + backgroundImage: subject.avatar != null ? NetworkImage(subject.avatar!) : null, 414 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 415 + child: subject.avatar == null 416 + ? Text( 417 + (subject.displayName?.isNotEmpty == true ? subject.displayName! : subject.handle) 418 + .substring(0, 1) 419 + .toUpperCase(), 420 + ) 421 + : null, 422 + ), 423 + title: Text(subject.displayName ?? subject.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 424 + subtitle: Text( 425 + '@${subject.handle}', 426 + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), 427 + ), 428 + onTap: () => context.push('/profile/view?actor=${subject.did}'), 429 + trailing: isOwn 430 + ? IconButton( 431 + icon: Icon(Icons.remove_circle_outline, color: Theme.of(context).colorScheme.error), 432 + onPressed: () => context.read<ListBloc>().add(ListItemRemoved(listItemUri: item.uri)), 433 + ) 434 + : null, 435 + ); 436 + }, 437 + ), 438 + ); 439 + } 440 + }
+208
lib/features/lists/presentation/list_members_screen.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 7 + import 'package:lazurite/features/lists/data/list_repository.dart'; 8 + 9 + /// Screen for adding and removing members from a list. 10 + /// 11 + /// Expects a [ListBloc] in scope (the router provides one). Shows a 12 + /// typeahead search field to add members and lists current members with 13 + /// remove buttons. 14 + class ListMembersScreen extends StatelessWidget { 15 + const ListMembersScreen({super.key, required this.listUri}); 16 + 17 + final AtUri listUri; 18 + 19 + @override 20 + Widget build(BuildContext context) => const _ListMembersView(); 21 + } 22 + 23 + class _ListMembersView extends StatefulWidget { 24 + const _ListMembersView(); 25 + 26 + @override 27 + State<_ListMembersView> createState() => _ListMembersViewState(); 28 + } 29 + 30 + class _ListMembersViewState extends State<_ListMembersView> { 31 + final _searchController = TextEditingController(); 32 + List<ProfileViewBasic> _searchResults = []; 33 + bool _isSearching = false; 34 + 35 + @override 36 + void dispose() { 37 + _searchController.dispose(); 38 + super.dispose(); 39 + } 40 + 41 + Future<void> _search(String query) async { 42 + if (query.trim().isEmpty) { 43 + setState(() => _searchResults = []); 44 + return; 45 + } 46 + 47 + setState(() => _isSearching = true); 48 + 49 + try { 50 + final results = await context.read<ListRepository>().searchActorsTypeahead(query: query.trim(), limit: 10); 51 + if (mounted) setState(() => _searchResults = results); 52 + } catch (_) { 53 + if (mounted) setState(() => _searchResults = []); 54 + } finally { 55 + if (mounted) setState(() => _isSearching = false); 56 + } 57 + } 58 + 59 + @override 60 + Widget build(BuildContext context) { 61 + return Scaffold( 62 + appBar: AppBar(title: const Text('Add members')), 63 + body: BlocBuilder<ListBloc, ListState>( 64 + builder: (context, state) { 65 + return Column( 66 + children: [ 67 + Padding( 68 + padding: const EdgeInsets.all(12), 69 + child: TextField( 70 + controller: _searchController, 71 + decoration: InputDecoration( 72 + hintText: 'Search for people', 73 + prefixIcon: const Icon(Icons.search), 74 + suffixIcon: _isSearching 75 + ? const Padding( 76 + padding: EdgeInsets.all(12), 77 + child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), 78 + ) 79 + : (_searchController.text.isNotEmpty 80 + ? IconButton( 81 + icon: const Icon(Icons.clear), 82 + onPressed: () { 83 + _searchController.clear(); 84 + setState(() => _searchResults = []); 85 + }, 86 + ) 87 + : null), 88 + border: const OutlineInputBorder(), 89 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 90 + ), 91 + onChanged: _search, 92 + ), 93 + ), 94 + if (_searchResults.isNotEmpty) 95 + _buildSearchResults(context, state) 96 + else 97 + Expanded(child: _buildCurrentMembers(context, state)), 98 + ], 99 + ); 100 + }, 101 + ), 102 + ); 103 + } 104 + 105 + Widget _buildSearchResults(BuildContext context, ListState state) { 106 + final currentDids = state.items.map((item) => item.subject.did).toSet(); 107 + final colorScheme = Theme.of(context).colorScheme; 108 + 109 + return Expanded( 110 + child: ListView.builder( 111 + itemCount: _searchResults.length, 112 + itemBuilder: (context, index) { 113 + final profile = _searchResults[index]; 114 + final isAlreadyMember = currentDids.contains(profile.did); 115 + 116 + return ListTile( 117 + leading: CircleAvatar( 118 + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 119 + backgroundColor: colorScheme.surfaceContainerHighest, 120 + child: profile.avatar == null 121 + ? Text( 122 + (profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle) 123 + .substring(0, 1) 124 + .toUpperCase(), 125 + ) 126 + : null, 127 + ), 128 + title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 129 + subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 130 + trailing: isAlreadyMember 131 + ? Icon(Icons.check_circle, color: colorScheme.primary) 132 + : IconButton( 133 + icon: const Icon(Icons.add_circle_outline), 134 + onPressed: () { 135 + context.read<ListBloc>().add(ListItemAdded(subjectDid: profile.did)); 136 + _searchController.clear(); 137 + setState(() => _searchResults = []); 138 + }, 139 + ), 140 + ); 141 + }, 142 + ), 143 + ); 144 + } 145 + 146 + Widget _buildCurrentMembers(BuildContext context, ListState state) { 147 + if (state.isLoading) { 148 + return const Center(child: CircularProgressIndicator()); 149 + } 150 + 151 + if (!state.hasItems) { 152 + return const Center(child: Text('No members yet. Search above to add people.')); 153 + } 154 + 155 + final colorScheme = Theme.of(context).colorScheme; 156 + 157 + return Column( 158 + crossAxisAlignment: CrossAxisAlignment.start, 159 + children: [ 160 + Padding( 161 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 162 + child: Text( 163 + 'CURRENT MEMBERS', 164 + style: TextStyle( 165 + fontSize: 11, 166 + fontWeight: FontWeight.w700, 167 + letterSpacing: 1.5, 168 + color: colorScheme.onSurfaceVariant, 169 + ), 170 + ), 171 + ), 172 + Expanded( 173 + child: ListView.builder( 174 + itemCount: state.items.length, 175 + itemBuilder: (context, index) { 176 + final item = state.items[index]; 177 + final subject = item.subject; 178 + 179 + return ListTile( 180 + key: ValueKey(item.uri), 181 + leading: CircleAvatar( 182 + backgroundImage: subject.avatar != null ? NetworkImage(subject.avatar!) : null, 183 + backgroundColor: colorScheme.surfaceContainerHighest, 184 + child: subject.avatar == null 185 + ? Text( 186 + (subject.displayName?.isNotEmpty == true ? subject.displayName! : subject.handle) 187 + .substring(0, 1) 188 + .toUpperCase(), 189 + ) 190 + : null, 191 + ), 192 + title: Text(subject.displayName ?? subject.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 193 + subtitle: Text('@${subject.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 194 + onTap: () => context.push('/profile/view?actor=${subject.did}'), 195 + trailing: state.isMutating 196 + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 197 + : IconButton( 198 + icon: Icon(Icons.remove_circle_outline, color: colorScheme.error), 199 + onPressed: () => context.read<ListBloc>().add(ListItemRemoved(listItemUri: item.uri)), 200 + ), 201 + ); 202 + }, 203 + ), 204 + ), 205 + ], 206 + ); 207 + } 208 + }
+138
lib/features/lists/presentation/my_lists_screen.dart
··· 1 + import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 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/auth/bloc/auth_bloc.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:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart'; 9 + import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; 10 + 11 + class MyListsScreen extends StatelessWidget { 12 + const MyListsScreen({super.key}); 13 + 14 + @override 15 + Widget build(BuildContext context) { 16 + final actor = context.read<AuthBloc>().state.tokens?.did ?? ''; 17 + return BlocProvider( 18 + create: (context) => MyListsCubit(listRepository: context.read<ListRepository>())..load(actor: actor), 19 + child: const _MyListsView(), 20 + ); 21 + } 22 + } 23 + 24 + class _MyListsView extends StatefulWidget { 25 + const _MyListsView(); 26 + 27 + @override 28 + State<_MyListsView> createState() => _MyListsViewState(); 29 + } 30 + 31 + class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderStateMixin { 32 + late final TabController _tabController; 33 + 34 + @override 35 + void initState() { 36 + super.initState(); 37 + _tabController = TabController(length: 2, vsync: this); 38 + } 39 + 40 + @override 41 + void dispose() { 42 + _tabController.dispose(); 43 + super.dispose(); 44 + } 45 + 46 + Future<void> _showCreateDialog(BuildContext context) async { 47 + final result = await showDialog<CreateEditListResult>( 48 + context: context, 49 + builder: (_) => const CreateEditListDialog(), 50 + ); 51 + 52 + if (result == null || !context.mounted) return; 53 + 54 + final authState = context.read<AuthBloc>().state; 55 + final userDid = authState.tokens?.did; 56 + if (userDid == null) return; 57 + 58 + final listUri = await context.read<MyListsCubit>().createList( 59 + userDid: userDid, 60 + name: result.name, 61 + purpose: result.purpose, 62 + description: result.description, 63 + avatarBytes: result.avatarBytes, 64 + avatarMimeType: result.avatarMimeType, 65 + ); 66 + 67 + if (listUri != null && context.mounted) { 68 + await context.push('/list?uri=${Uri.encodeComponent(listUri.toString())}'); 69 + } 70 + } 71 + 72 + @override 73 + Widget build(BuildContext context) { 74 + return Scaffold( 75 + appBar: AppBar( 76 + title: const Text('My Lists'), 77 + bottom: TabBar( 78 + controller: _tabController, 79 + tabs: const [ 80 + Tab(text: 'FEEDS'), 81 + Tab(text: 'MODERATION'), 82 + ], 83 + labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 84 + unselectedLabelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 85 + indicatorWeight: 2, 86 + ), 87 + ), 88 + body: BlocBuilder<MyListsCubit, MyListsState>( 89 + builder: (context, state) { 90 + if (state.status == MyListsStatus.loading) { 91 + return const Center(child: CircularProgressIndicator()); 92 + } 93 + 94 + if (state.status == MyListsStatus.error) { 95 + return Center( 96 + child: Column( 97 + mainAxisSize: MainAxisSize.min, 98 + children: [ 99 + Text(state.errorMessage ?? 'Failed to load lists'), 100 + const SizedBox(height: 12), 101 + FilledButton(onPressed: () => context.read<MyListsCubit>().refresh(), child: const Text('Retry')), 102 + ], 103 + ), 104 + ); 105 + } 106 + 107 + return TabBarView( 108 + controller: _tabController, 109 + children: [_buildListTab(context, state.curationLists), _buildListTab(context, state.moderationLists)], 110 + ); 111 + }, 112 + ), 113 + floatingActionButton: FloatingActionButton( 114 + heroTag: 'my-lists-fab', 115 + onPressed: () => _showCreateDialog(context), 116 + child: const Icon(Icons.add), 117 + ), 118 + ); 119 + } 120 + 121 + Widget _buildListTab(BuildContext context, List<bsky_graph.ListView> lists) { 122 + if (lists.isEmpty) { 123 + return const Center(child: Text('No lists yet')); 124 + } 125 + 126 + return RefreshIndicator( 127 + onRefresh: () => context.read<MyListsCubit>().refresh(), 128 + child: ListView.builder( 129 + itemCount: lists.length, 130 + itemBuilder: (context, index) => ListRowTile( 131 + key: ValueKey(lists[index].uri), 132 + list: lists[index], 133 + onTap: () => context.push('/list?uri=${Uri.encodeComponent(lists[index].uri.toString())}'), 134 + ), 135 + ), 136 + ); 137 + } 138 + }
+224
lib/features/lists/presentation/widgets/create_edit_list_dialog.dart
··· 1 + import 'dart:io'; 2 + import 'dart:typed_data'; 3 + 4 + import 'package:bluesky/app_bsky_graph_defs.dart' show KnownListPurpose; 5 + import 'package:flutter/material.dart'; 6 + import 'package:image_picker/image_picker.dart'; 7 + 8 + class CreateEditListResult { 9 + const CreateEditListResult({ 10 + required this.name, 11 + required this.purpose, 12 + this.description, 13 + this.avatarBytes, 14 + this.avatarMimeType = 'image/jpeg', 15 + }); 16 + 17 + final String name; 18 + final String purpose; 19 + final String? description; 20 + final Uint8List? avatarBytes; 21 + final String avatarMimeType; 22 + } 23 + 24 + /// A dialog for creating a new list or editing an existing one. 25 + /// 26 + /// Returns a [CreateEditListResult] on save, or null on cancel. 27 + class CreateEditListDialog extends StatefulWidget { 28 + const CreateEditListDialog({ 29 + super.key, 30 + this.initialName, 31 + this.initialDescription, 32 + this.initialAvatarUrl, 33 + this.initialPurpose, 34 + this.fixedPurpose, 35 + }); 36 + 37 + final String? initialName; 38 + final String? initialDescription; 39 + final String? initialAvatarUrl; 40 + 41 + /// Pre-selected purpose for a new list. 42 + final KnownListPurpose? initialPurpose; 43 + 44 + /// When non-null, purpose cannot be changed (edit mode). 45 + final String? fixedPurpose; 46 + 47 + @override 48 + State<CreateEditListDialog> createState() => _CreateEditListDialogState(); 49 + } 50 + 51 + class _CreateEditListDialogState extends State<CreateEditListDialog> { 52 + late final TextEditingController _nameController; 53 + late final TextEditingController _descController; 54 + late KnownListPurpose _purpose; 55 + 56 + Uint8List? _avatarBytes; 57 + String _avatarMimeType = 'image/jpeg'; 58 + bool _isPicking = false; 59 + 60 + final ImagePicker _picker = ImagePicker(); 61 + 62 + bool get _isEditing => widget.fixedPurpose != null; 63 + 64 + @override 65 + void initState() { 66 + super.initState(); 67 + _nameController = TextEditingController(text: widget.initialName ?? ''); 68 + _descController = TextEditingController(text: widget.initialDescription ?? ''); 69 + _purpose = widget.initialPurpose ?? KnownListPurpose.appBskyGraphDefsCuratelist; 70 + _nameController.addListener(() => setState(() {})); 71 + } 72 + 73 + @override 74 + void dispose() { 75 + _nameController.dispose(); 76 + _descController.dispose(); 77 + super.dispose(); 78 + } 79 + 80 + Future<void> _pickAvatar() async { 81 + if (_isPicking) return; 82 + setState(() => _isPicking = true); 83 + 84 + try { 85 + final XFile? file = await _picker.pickImage( 86 + source: ImageSource.gallery, 87 + maxWidth: 1000, 88 + maxHeight: 1000, 89 + imageQuality: 85, 90 + ); 91 + if (file != null && mounted) { 92 + final bytes = await File(file.path).readAsBytes(); 93 + final ext = file.path.toLowerCase().split('.').last; 94 + setState(() { 95 + _avatarBytes = bytes; 96 + _avatarMimeType = ext == 'png' ? 'image/png' : 'image/jpeg'; 97 + }); 98 + } 99 + } finally { 100 + if (mounted) setState(() => _isPicking = false); 101 + } 102 + } 103 + 104 + void _save() { 105 + final name = _nameController.text.trim(); 106 + if (name.isEmpty) return; 107 + 108 + final purposeStr = 109 + widget.fixedPurpose ?? 110 + (_purpose == KnownListPurpose.appBskyGraphDefsModlist 111 + ? 'app.bsky.graph.defs#modlist' 112 + : 'app.bsky.graph.defs#curatelist'); 113 + 114 + Navigator.pop( 115 + context, 116 + CreateEditListResult( 117 + name: name, 118 + purpose: purposeStr, 119 + description: _descController.text.trim().isEmpty ? null : _descController.text.trim(), 120 + avatarBytes: _avatarBytes, 121 + avatarMimeType: _avatarMimeType, 122 + ), 123 + ); 124 + } 125 + 126 + @override 127 + Widget build(BuildContext context) { 128 + final colorScheme = Theme.of(context).colorScheme; 129 + final hasAvatar = _avatarBytes != null || widget.initialAvatarUrl != null; 130 + 131 + return AlertDialog( 132 + title: Text(_isEditing ? 'Edit list' : 'Create list'), 133 + content: SizedBox( 134 + width: double.maxFinite, 135 + child: SingleChildScrollView( 136 + child: Column( 137 + mainAxisSize: MainAxisSize.min, 138 + crossAxisAlignment: CrossAxisAlignment.start, 139 + children: [ 140 + Center( 141 + child: GestureDetector( 142 + onTap: _pickAvatar, 143 + child: Stack( 144 + children: [ 145 + CircleAvatar( 146 + radius: 40, 147 + backgroundColor: colorScheme.surfaceContainerHighest, 148 + backgroundImage: _avatarBytes != null 149 + ? MemoryImage(_avatarBytes!) 150 + : (widget.initialAvatarUrl != null ? NetworkImage(widget.initialAvatarUrl!) : null), 151 + child: !hasAvatar ? Icon(Icons.list, size: 32, color: colorScheme.onSurfaceVariant) : null, 152 + ), 153 + Positioned( 154 + right: 0, 155 + bottom: 0, 156 + child: CircleAvatar( 157 + radius: 14, 158 + backgroundColor: colorScheme.primaryContainer, 159 + child: _isPicking 160 + ? SizedBox( 161 + width: 14, 162 + height: 14, 163 + child: CircularProgressIndicator( 164 + strokeWidth: 2, 165 + color: colorScheme.onPrimaryContainer, 166 + ), 167 + ) 168 + : Icon(Icons.camera_alt, size: 16, color: colorScheme.onPrimaryContainer), 169 + ), 170 + ), 171 + ], 172 + ), 173 + ), 174 + ), 175 + const SizedBox(height: 16), 176 + TextField( 177 + controller: _nameController, 178 + decoration: const InputDecoration(labelText: 'Name', border: OutlineInputBorder()), 179 + maxLength: 64, 180 + textCapitalization: TextCapitalization.sentences, 181 + ), 182 + const SizedBox(height: 12), 183 + TextField( 184 + controller: _descController, 185 + decoration: const InputDecoration(labelText: 'Description (optional)', border: OutlineInputBorder()), 186 + maxLength: 300, 187 + maxLines: 3, 188 + textCapitalization: TextCapitalization.sentences, 189 + ), 190 + if (!_isEditing) ...[ 191 + const SizedBox(height: 12), 192 + Text('Type', style: Theme.of(context).textTheme.labelLarge), 193 + const SizedBox(height: 8), 194 + SegmentedButton<KnownListPurpose>( 195 + segments: const [ 196 + ButtonSegment( 197 + value: KnownListPurpose.appBskyGraphDefsCuratelist, 198 + label: Text('Feed'), 199 + icon: Icon(Icons.dynamic_feed_outlined), 200 + ), 201 + ButtonSegment( 202 + value: KnownListPurpose.appBskyGraphDefsModlist, 203 + label: Text('Moderation'), 204 + icon: Icon(Icons.shield_outlined), 205 + ), 206 + ], 207 + selected: {_purpose}, 208 + onSelectionChanged: (selection) => setState(() => _purpose = selection.first), 209 + ), 210 + ], 211 + ], 212 + ), 213 + ), 214 + ), 215 + actions: [ 216 + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 217 + FilledButton( 218 + onPressed: _nameController.text.trim().isEmpty ? null : _save, 219 + child: Text(_isEditing ? 'Save' : 'Create'), 220 + ), 221 + ], 222 + ); 223 + } 224 + }
+45
lib/features/lists/presentation/widgets/list_row_tile.dart
··· 1 + import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 2 + import 'package:flutter/material.dart'; 3 + 4 + /// A reusable tile for a single [bsky_graph.ListView] entry. 5 + class ListRowTile extends StatelessWidget { 6 + const ListRowTile({super.key, required this.list, this.onTap, this.trailing}); 7 + 8 + final bsky_graph.ListView list; 9 + final VoidCallback? onTap; 10 + final Widget? trailing; 11 + 12 + @override 13 + Widget build(BuildContext context) { 14 + final colorScheme = Theme.of(context).colorScheme; 15 + final isMod = list.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist; 16 + final purposeLabel = isMod ? 'MOD' : 'FEED'; 17 + final purposeColor = isMod ? colorScheme.error : colorScheme.primary; 18 + 19 + return ListTile( 20 + key: key, 21 + leading: CircleAvatar( 22 + backgroundImage: list.avatar != null ? NetworkImage(list.avatar!) : null, 23 + backgroundColor: colorScheme.surfaceContainerHighest, 24 + child: list.avatar == null ? Icon(Icons.list, color: colorScheme.onSurfaceVariant) : null, 25 + ), 26 + title: Text(list.name, maxLines: 1, overflow: TextOverflow.ellipsis), 27 + subtitle: Text('${list.listItemCount ?? 0} members', style: TextStyle(color: colorScheme.onSurfaceVariant)), 28 + trailing: 29 + trailing ?? 30 + Container( 31 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 32 + decoration: BoxDecoration( 33 + color: purposeColor.withValues(alpha: 0.1), 34 + borderRadius: BorderRadius.circular(4), 35 + border: Border.all(color: purposeColor.withValues(alpha: 0.3)), 36 + ), 37 + child: Text( 38 + purposeLabel, 39 + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: purposeColor, letterSpacing: 1), 40 + ), 41 + ), 42 + onTap: onTap, 43 + ); 44 + } 45 + }
+194 -24
lib/features/profile/presentation/profile_screen.dart
··· 1 - import 'dart:ui'; 2 - 3 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 4 3 import 'package:bluesky/moderation.dart' as bsky_moderation; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter/services.dart'; ··· 9 8 import 'package:intl/intl.dart'; 10 9 import 'package:lazurite/core/router/app_shell.dart'; 11 10 import 'package:lazurite/core/theme/feed_architecture.dart'; 11 + import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; 12 12 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 13 13 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 14 14 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 15 15 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 16 + import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; 17 + import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; 18 + import 'package:lazurite/features/lists/data/list_repository.dart'; 19 + import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; 16 20 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 17 21 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 18 22 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; ··· 59 63 } 60 64 61 65 class _ProfileScreenState extends State<ProfileScreen> with SingleTickerProviderStateMixin { 62 - static const _tabs = [ 66 + static const _feedTabs = [ 63 67 (label: 'Posts', filter: FeedFilter.postsNoReplies), 64 68 (label: 'Replies', filter: FeedFilter.postsAndAuthorThreads), 65 69 (label: 'Media', filter: FeedFilter.postsWithMedia), 66 70 ]; 67 71 72 + static const _tabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS']; 73 + 68 74 late final TabController _tabController; 69 75 70 76 @override 71 77 void initState() { 72 78 super.initState(); 73 - _tabController = TabController(length: _tabs.length, vsync: this); 79 + _tabController = TabController(length: _tabLabels.length, vsync: this); 74 80 _loadProfileAndFeed(); 75 81 } 76 82 ··· 102 108 return widget.actor ?? authState.tokens?.did; 103 109 } 104 110 105 - FeedFilter get _currentFilter => _tabs[_tabController.index].filter; 111 + FeedFilter get _currentFilter => _feedTabs[_tabController.index < _feedTabs.length ? _tabController.index : 0].filter; 106 112 107 113 String _appBarTitle(ProfileViewDetailed? profile) { 108 114 final authState = context.read<AuthBloc>().state; ··· 157 163 ), 158 164 SliverPersistentHeader( 159 165 pinned: true, 160 - delegate: _SliverTabBarDelegate( 166 + delegate: SliverTabBarDelegate( 161 167 TabBar( 162 168 controller: _tabController, 163 - tabs: [for (final tab in _tabs) Tab(text: tab.label.toUpperCase())], 164 - onTap: (index) => _loadProfileAndFeed(filter: _tabs[index].filter), 169 + tabs: [for (final label in _tabLabels) Tab(text: label)], 170 + onTap: (index) { 171 + if (index < _feedTabs.length) { 172 + _loadProfileAndFeed(filter: _feedTabs[index].filter); 173 + } 174 + }, 165 175 labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 166 176 unselectedLabelStyle: const TextStyle( 167 177 fontSize: 11, ··· 177 187 body: TabBarView( 178 188 controller: _tabController, 179 189 children: [ 180 - for (var i = 0; i < _tabs.length; i++) _buildFeedList(feedState, _tabs[i].filter, profile), 190 + for (var i = 0; i < _feedTabs.length; i++) _buildFeedList(feedState, _feedTabs[i].filter, profile), 191 + _buildListsTab(context, profile), 181 192 ], 182 193 ), 183 194 ); ··· 441 452 onBlock: () => context.read<ProfileActionCubit>().toggleBlock(), 442 453 onUnblock: () => context.read<ProfileActionCubit>().toggleBlock(), 443 454 onMore: () => _showProfileMoreOptions(context, profile), 455 + onAddToList: () => _showAddToList(context, profile), 444 456 ), 445 457 ), 446 458 ); ··· 473 485 Share.share(url); 474 486 }, 475 487 ), 488 + ListTile( 489 + leading: const Icon(Icons.playlist_add_outlined), 490 + title: const Text('Add to list'), 491 + onTap: () { 492 + Navigator.pop(sheetContext); 493 + _showAddToList(context, profile); 494 + }, 495 + ), 476 496 ], 477 497 ), 478 498 ), 479 499 ); 480 500 } 481 501 502 + void _showAddToList(BuildContext context, ProfileViewDetailed profile) { 503 + ListRepository? listRepository; 504 + try { 505 + listRepository = context.read<ListRepository>(); 506 + } catch (_) { 507 + return; 508 + } 509 + 510 + final currentUserDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 511 + final cubit = AddToListCubit(listRepository: listRepository, currentUserDid: currentUserDid) 512 + ..load(targetDid: profile.did); 513 + 514 + showModalBottomSheet<void>( 515 + context: context, 516 + isScrollControlled: true, 517 + builder: (sheetContext) => BlocProvider.value( 518 + value: cubit, 519 + child: DraggableScrollableSheet( 520 + initialChildSize: 0.6, 521 + maxChildSize: 0.9, 522 + minChildSize: 0.3, 523 + expand: false, 524 + builder: (_, scrollController) => Column( 525 + children: [ 526 + Padding( 527 + padding: const EdgeInsets.symmetric(vertical: 12), 528 + child: Text( 529 + 'Add to list', 530 + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 531 + ), 532 + ), 533 + const Divider(height: 1), 534 + Expanded( 535 + child: BlocBuilder<AddToListCubit, AddToListState>( 536 + builder: (context, state) { 537 + if (state.status == AddToListStatus.loading) { 538 + return const Center(child: CircularProgressIndicator()); 539 + } 540 + 541 + if (state.status == AddToListStatus.error) { 542 + return Center(child: Text(state.errorMessage ?? 'Failed to load lists')); 543 + } 544 + 545 + if (state.lists.isEmpty) { 546 + return const Center(child: Text('No lists yet')); 547 + } 548 + 549 + return ListView.builder( 550 + controller: scrollController, 551 + itemCount: state.lists.length, 552 + itemBuilder: (context, index) { 553 + final entry = state.lists[index]; 554 + final isMember = entry.listItem != null; 555 + final isToggling = state.togglingUris.contains(entry.list.uri.toString()); 556 + 557 + return ListRowTile( 558 + list: entry.list, 559 + trailing: isToggling 560 + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) 561 + : Icon( 562 + isMember ? Icons.check_circle : Icons.add_circle_outline, 563 + color: isMember 564 + ? Theme.of(context).colorScheme.primary 565 + : Theme.of(context).colorScheme.onSurfaceVariant, 566 + ), 567 + onTap: isToggling ? null : () => context.read<AddToListCubit>().toggleMembership(entry), 568 + ); 569 + }, 570 + ); 571 + }, 572 + ), 573 + ), 574 + ], 575 + ), 576 + ), 577 + ), 578 + ).whenComplete(cubit.close); 579 + } 580 + 482 581 Widget? _buildComposeFab(BuildContext context) { 483 582 return BlocBuilder<ProfileBloc, ProfileState>( 484 583 builder: (context, state) { ··· 606 705 ); 607 706 } 608 707 708 + Widget _buildListsTab(BuildContext context, ProfileViewDetailed? profile) { 709 + final actor = profile?.did ?? _resolvedActor; 710 + if (actor == null) return const SizedBox.shrink(); 711 + 712 + ListRepository? listRepository; 713 + try { 714 + listRepository = context.read<ListRepository>(); 715 + } catch (_) { 716 + return const SizedBox.shrink(); 717 + } 718 + 719 + return _ProfileListsPane(actor: actor, listRepository: listRepository); 720 + } 721 + 609 722 String _emptyLabel(FeedFilter filter) { 610 723 switch (filter) { 611 724 case FeedFilter.postsNoReplies: ··· 637 750 } 638 751 } 639 752 640 - /// Sticky tab bar delegate with backdrop blur background and uppercase styled labels. 641 - class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate { 642 - _SliverTabBarDelegate(this.tabBar); 753 + /// Pane that loads and displays lists for a given [actor] within the profile screen. 754 + class _ProfileListsPane extends StatefulWidget { 755 + const _ProfileListsPane({required this.actor, required this.listRepository}); 756 + 757 + final String actor; 758 + final ListRepository listRepository; 759 + 760 + @override 761 + State<_ProfileListsPane> createState() => _ProfileListsPaneState(); 762 + } 643 763 644 - final TabBar tabBar; 764 + class _ProfileListsPaneState extends State<_ProfileListsPane> { 765 + late final MyListsCubit _cubit; 645 766 646 767 @override 647 - double get minExtent => tabBar.preferredSize.height; 768 + void initState() { 769 + super.initState(); 770 + _cubit = MyListsCubit(listRepository: widget.listRepository)..load(actor: widget.actor); 771 + } 648 772 649 773 @override 650 - double get maxExtent => tabBar.preferredSize.height; 774 + void didUpdateWidget(_ProfileListsPane oldWidget) { 775 + super.didUpdateWidget(oldWidget); 776 + if (oldWidget.actor != widget.actor) { 777 + _cubit.load(actor: widget.actor); 778 + } 779 + } 651 780 652 781 @override 653 - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { 654 - final colorScheme = Theme.of(context).colorScheme; 655 - return ClipRect( 656 - child: BackdropFilter( 657 - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), 658 - child: ColoredBox(color: colorScheme.surface.withValues(alpha: 0.85), child: tabBar), 659 - ), 660 - ); 782 + void dispose() { 783 + _cubit.close(); 784 + super.dispose(); 661 785 } 662 786 663 787 @override 664 - bool shouldRebuild(_SliverTabBarDelegate oldDelegate) => false; 788 + Widget build(BuildContext context) { 789 + return BlocBuilder<MyListsCubit, MyListsState>( 790 + bloc: _cubit, 791 + builder: (context, state) { 792 + if (state.status == MyListsStatus.loading) { 793 + return const Center(child: CircularProgressIndicator()); 794 + } 795 + 796 + if (state.status == MyListsStatus.error) { 797 + return Center( 798 + child: Column( 799 + mainAxisSize: MainAxisSize.min, 800 + children: [ 801 + Text(state.errorMessage ?? 'Failed to load lists'), 802 + const SizedBox(height: 12), 803 + FilledButton(onPressed: () => _cubit.refresh(), child: const Text('Retry')), 804 + ], 805 + ), 806 + ); 807 + } 808 + 809 + final lists = state.lists 810 + .where((l) { 811 + final purpose = l.purpose.knownValue; 812 + return purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist || 813 + purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist; 814 + }) 815 + .toList(growable: false); 816 + 817 + if (lists.isEmpty) { 818 + return const Center(child: Text('No lists yet')); 819 + } 820 + 821 + return RefreshIndicator( 822 + onRefresh: _cubit.refresh, 823 + child: ListView.builder( 824 + itemCount: lists.length, 825 + itemBuilder: (context, index) => ListRowTile( 826 + key: ValueKey(lists[index].uri), 827 + list: lists[index], 828 + onTap: () => context.push('/list?uri=${Uri.encodeComponent(lists[index].uri.toString())}'), 829 + ), 830 + ), 831 + ); 832 + }, 833 + ); 834 + } 665 835 }
+11
lib/features/profile/presentation/widgets/profile_action_buttons.dart
··· 18 18 this.onBlock, 19 19 this.onUnblock, 20 20 this.onMore, 21 + this.onAddToList, 21 22 }); 22 23 23 24 final bool isFollowing; ··· 34 35 final VoidCallback? onBlock; 35 36 final VoidCallback? onUnblock; 36 37 final VoidCallback? onMore; 38 + final VoidCallback? onAddToList; 37 39 38 40 @override 39 41 Widget build(BuildContext context) { ··· 100 102 ], 101 103 ), 102 104 onTap: () => _confirmBlock(context), 105 + ), 106 + ); 107 + } 108 + 109 + if (onAddToList != null) { 110 + menuItems.add( 111 + PopupMenuItem( 112 + onTap: onAddToList, 113 + child: const Row(children: [Icon(Icons.playlist_add_outlined), SizedBox(width: 8), Text('Add to list')]), 103 114 ), 104 115 ); 105 116 }
+144
test/features/lists/cubit/add_to_list_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:bluesky/app_bsky_graph_getlistswithmembership.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; 8 + import 'package:lazurite/features/lists/data/list_repository.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockListRepository extends Mock implements ListRepository {} 12 + 13 + void main() { 14 + late MockListRepository mockRepo; 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 + 19 + final listView = ListView( 20 + uri: listUri, 21 + cid: 'cid-list', 22 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 23 + name: 'My Feed', 24 + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsCuratelist), 25 + indexedAt: DateTime.utc(2026, 3, 21), 26 + ); 27 + 28 + final listItem = ListItemView( 29 + uri: listItemUri, 30 + subject: const ProfileView(did: 'did:plc:target', handle: 'target.bsky.social'), 31 + ); 32 + 33 + final entryWithMember = ListWithMembership(list: listView, listItem: listItem); 34 + final entryWithoutMember = ListWithMembership(list: listView); 35 + 36 + setUpAll(() { 37 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.list/fallback')); 38 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listitem/fallback')); 39 + }); 40 + 41 + setUp(() { 42 + mockRepo = MockListRepository(); 43 + }); 44 + 45 + group('AddToListCubit', () { 46 + blocTest<AddToListCubit, AddToListState>( 47 + 'load emits loading then loaded with membership data', 48 + build: () => AddToListCubit(listRepository: mockRepo, currentUserDid: 'did:plc:me'), 49 + setUp: () { 50 + when( 51 + () => mockRepo.getListsWithMembership( 52 + actor: 'did:plc:target', 53 + cursor: any(named: 'cursor'), 54 + limit: any(named: 'limit'), 55 + ), 56 + ).thenAnswer((_) async => ListsWithMembershipResult(lists: [entryWithMember])); 57 + }, 58 + act: (cubit) => cubit.load(targetDid: 'did:plc:target'), 59 + expect: () => [ 60 + const AddToListState.loading(targetDid: 'did:plc:target'), 61 + AddToListState.loaded(targetDid: 'did:plc:target', lists: [entryWithMember]), 62 + ], 63 + ); 64 + 65 + blocTest<AddToListCubit, AddToListState>( 66 + 'load emits error when repository throws', 67 + build: () => AddToListCubit(listRepository: mockRepo, currentUserDid: 'did:plc:me'), 68 + setUp: () { 69 + when( 70 + () => mockRepo.getListsWithMembership( 71 + actor: any(named: 'actor'), 72 + cursor: any(named: 'cursor'), 73 + limit: any(named: 'limit'), 74 + ), 75 + ).thenThrow(Exception('network error')); 76 + }, 77 + act: (cubit) => cubit.load(targetDid: 'did:plc:target'), 78 + expect: () => [ 79 + const AddToListState.loading(targetDid: 'did:plc:target'), 80 + predicate<AddToListState>((s) => s.status == AddToListStatus.error && s.errorMessage != null), 81 + ], 82 + ); 83 + 84 + blocTest<AddToListCubit, AddToListState>( 85 + 'toggleMembership removes member when listItem is present', 86 + build: () => AddToListCubit(listRepository: mockRepo, currentUserDid: 'did:plc:me'), 87 + seed: () => AddToListState.loaded(targetDid: 'did:plc:target', lists: [entryWithMember]), 88 + setUp: () { 89 + when(() => mockRepo.removeListItem(listItemUri: listItemUri)).thenAnswer((_) async {}); 90 + when( 91 + () => mockRepo.getListsWithMembership( 92 + actor: 'did:plc:target', 93 + cursor: any(named: 'cursor'), 94 + limit: any(named: 'limit'), 95 + ), 96 + ).thenAnswer((_) async => ListsWithMembershipResult(lists: [entryWithoutMember])); 97 + }, 98 + act: (cubit) => cubit.toggleMembership(entryWithMember), 99 + expect: () => [ 100 + predicate<AddToListState>((s) => s.togglingUris.contains(listUri.toString())), 101 + predicate<AddToListState>((s) => s.togglingUris.isEmpty && s.lists.single.listItem == null), 102 + ], 103 + verify: (_) => verify(() => mockRepo.removeListItem(listItemUri: listItemUri)).called(1), 104 + ); 105 + 106 + blocTest<AddToListCubit, AddToListState>( 107 + 'toggleMembership adds member when listItem is absent', 108 + build: () => AddToListCubit(listRepository: mockRepo, currentUserDid: 'did:plc:me'), 109 + seed: () => AddToListState.loaded(targetDid: 'did:plc:target', lists: [entryWithoutMember]), 110 + setUp: () { 111 + when( 112 + () => mockRepo.addListItem(listUri: listUri, subjectDid: 'did:plc:target'), 113 + ).thenAnswer((_) async => listItemUri.toString()); 114 + when( 115 + () => mockRepo.getListsWithMembership( 116 + actor: 'did:plc:target', 117 + cursor: any(named: 'cursor'), 118 + limit: any(named: 'limit'), 119 + ), 120 + ).thenAnswer((_) async => ListsWithMembershipResult(lists: [entryWithMember])); 121 + }, 122 + act: (cubit) => cubit.toggleMembership(entryWithoutMember), 123 + expect: () => [ 124 + predicate<AddToListState>((s) => s.togglingUris.contains(listUri.toString())), 125 + predicate<AddToListState>((s) => s.togglingUris.isEmpty && s.lists.single.listItem != null), 126 + ], 127 + verify: (_) => verify(() => mockRepo.addListItem(listUri: listUri, subjectDid: 'did:plc:target')).called(1), 128 + ); 129 + 130 + blocTest<AddToListCubit, AddToListState>( 131 + 'toggleMembership clears toggling URI on failure', 132 + build: () => AddToListCubit(listRepository: mockRepo, currentUserDid: 'did:plc:me'), 133 + seed: () => AddToListState.loaded(targetDid: 'did:plc:target', lists: [entryWithMember]), 134 + setUp: () { 135 + when(() => mockRepo.removeListItem(listItemUri: listItemUri)).thenThrow(Exception('network error')); 136 + }, 137 + act: (cubit) => cubit.toggleMembership(entryWithMember), 138 + expect: () => [ 139 + predicate<AddToListState>((s) => s.togglingUris.contains(listUri.toString())), 140 + predicate<AddToListState>((s) => s.togglingUris.isEmpty), 141 + ], 142 + ); 143 + }); 144 + }
+209
test/features/lists/presentation/list_detail_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:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 11 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 12 + import 'package:lazurite/features/lists/data/list_repository.dart'; 13 + import 'package:lazurite/features/lists/presentation/list_detail_screen.dart'; 14 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 15 + import 'package:mocktail/mocktail.dart'; 16 + 17 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 18 + 19 + class MockListRepository extends Mock implements ListRepository {} 20 + 21 + class MockModerationService extends Mock implements ModerationService {} 22 + 23 + class MockPostActionRepository extends Mock implements PostActionRepository {} 24 + 25 + class MockPostActionCache extends Mock implements PostActionCache {} 26 + 27 + void main() { 28 + late MockAuthBloc authBloc; 29 + late MockListRepository listRepository; 30 + 31 + const tokens = AuthTokens( 32 + accessToken: 'access', 33 + refreshToken: 'refresh', 34 + did: 'did:plc:me', 35 + handle: 'me.bsky.social', 36 + ); 37 + 38 + final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); 39 + 40 + final curationList = bsky_graph.ListView( 41 + uri: listUri, 42 + cid: 'cid-1', 43 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 44 + name: 'Awesome Feed', 45 + description: 'A curated list of posts', 46 + purpose: const bsky_graph.ListPurpose.knownValue(data: bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist), 47 + listItemCount: 5, 48 + indexedAt: DateTime.utc(2026, 3, 21), 49 + ); 50 + 51 + final member = bsky_graph.ListItemView( 52 + uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'), 53 + subject: const ProfileView(did: 'did:plc:member', handle: 'member.bsky.social', displayName: 'A Member'), 54 + ); 55 + 56 + setUp(() { 57 + authBloc = MockAuthBloc(); 58 + listRepository = MockListRepository(); 59 + 60 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 61 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 62 + 63 + when( 64 + () => listRepository.getList( 65 + listUri: listUri, 66 + cursor: any(named: 'cursor'), 67 + limit: any(named: 'limit'), 68 + ), 69 + ).thenAnswer((_) async => ListDetailResult(list: curationList, items: [member], cursor: null)); 70 + 71 + when( 72 + () => listRepository.getListFeed( 73 + listUri: listUri, 74 + cursor: any(named: 'cursor'), 75 + limit: any(named: 'limit'), 76 + ), 77 + ).thenAnswer((_) async => const ListFeedResult(posts: [])); 78 + }); 79 + 80 + Widget buildSubject() { 81 + return MultiBlocProvider( 82 + providers: [BlocProvider<AuthBloc>.value(value: authBloc)], 83 + child: MultiRepositoryProvider( 84 + providers: [ 85 + RepositoryProvider<ListRepository>.value(value: listRepository), 86 + RepositoryProvider<PostActionRepository>.value(value: MockPostActionRepository()), 87 + RepositoryProvider<PostActionCache>.value(value: MockPostActionCache()), 88 + ], 89 + child: MaterialApp(home: ListDetailScreen(listUri: listUri)), 90 + ), 91 + ); 92 + } 93 + 94 + testWidgets('shows loading state initially', (tester) async { 95 + when( 96 + () => listRepository.getList( 97 + listUri: listUri, 98 + cursor: any(named: 'cursor'), 99 + limit: any(named: 'limit'), 100 + ), 101 + ).thenAnswer((_) async { 102 + await Future<void>.delayed(const Duration(hours: 1)); 103 + return ListDetailResult(list: curationList, items: [], cursor: null); 104 + }); 105 + 106 + await tester.pumpWidget(buildSubject()); 107 + await tester.pump(); // allow bloc event to be processed 108 + expect(find.byType(CircularProgressIndicator), findsWidgets); 109 + await tester.pump(const Duration(hours: 2)); // drain pending timers 110 + }); 111 + 112 + testWidgets('shows list name in app bar after loading', (tester) async { 113 + await tester.pumpWidget(buildSubject()); 114 + await tester.pumpAndSettle(); 115 + 116 + expect(find.text('Awesome Feed'), findsWidgets); 117 + }); 118 + 119 + testWidgets('shows list header with description and creator', (tester) async { 120 + await tester.pumpWidget(buildSubject()); 121 + await tester.pumpAndSettle(); 122 + 123 + expect(find.text('A curated list of posts'), findsOneWidget); 124 + expect(find.text('by @creator.bsky.social'), findsOneWidget); 125 + }); 126 + 127 + testWidgets('shows FEED and MEMBERS tabs', (tester) async { 128 + await tester.pumpWidget(buildSubject()); 129 + await tester.pumpAndSettle(); 130 + 131 + expect(find.text('FEED'), findsOneWidget); 132 + expect(find.text('MEMBERS'), findsOneWidget); 133 + }); 134 + 135 + testWidgets('shows members in MEMBERS tab', (tester) async { 136 + await tester.pumpWidget(buildSubject()); 137 + await tester.pumpAndSettle(); 138 + 139 + await tester.tap(find.text('MEMBERS')); 140 + await tester.pumpAndSettle(); 141 + 142 + expect(find.text('A Member'), findsOneWidget); 143 + expect(find.text('@member.bsky.social'), findsOneWidget); 144 + }); 145 + 146 + testWidgets('shows feed unavailable message for moderation list in FEED tab', (tester) async { 147 + final modList = bsky_graph.ListView( 148 + uri: listUri, 149 + cid: 'cid-1', 150 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 151 + name: 'Mod List', 152 + purpose: const bsky_graph.ListPurpose.knownValue(data: bsky_graph.KnownListPurpose.appBskyGraphDefsModlist), 153 + indexedAt: DateTime.utc(2026, 3, 21), 154 + ); 155 + 156 + when( 157 + () => listRepository.getList( 158 + listUri: listUri, 159 + cursor: any(named: 'cursor'), 160 + limit: any(named: 'limit'), 161 + ), 162 + ).thenAnswer((_) async => ListDetailResult(list: modList, items: [], cursor: null)); 163 + 164 + await tester.pumpWidget(buildSubject()); 165 + await tester.pumpAndSettle(); 166 + 167 + expect(find.text('Feed not available for moderation lists'), findsOneWidget); 168 + }); 169 + 170 + testWidgets('shows more options button', (tester) async { 171 + await tester.pumpWidget(buildSubject()); 172 + await tester.pumpAndSettle(); 173 + 174 + expect(find.byIcon(Icons.more_vert), findsOneWidget); 175 + }); 176 + 177 + testWidgets('remove button shown for own list members', (tester) async { 178 + when(() => authBloc.state).thenReturn( 179 + const AuthState.authenticated( 180 + AuthTokens( 181 + accessToken: 'access', 182 + refreshToken: 'refresh', 183 + did: 'did:plc:creator', 184 + handle: 'creator.bsky.social', 185 + ), 186 + ), 187 + ); 188 + whenListen( 189 + authBloc, 190 + const Stream<AuthState>.empty(), 191 + initialState: const AuthState.authenticated( 192 + AuthTokens( 193 + accessToken: 'access', 194 + refreshToken: 'refresh', 195 + did: 'did:plc:creator', 196 + handle: 'creator.bsky.social', 197 + ), 198 + ), 199 + ); 200 + 201 + await tester.pumpWidget(buildSubject()); 202 + await tester.pumpAndSettle(); 203 + 204 + await tester.tap(find.text('MEMBERS')); 205 + await tester.pumpAndSettle(); 206 + 207 + expect(find.byIcon(Icons.remove_circle_outline), findsOneWidget); 208 + }); 209 + }
+148
test/features/lists/presentation/list_members_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:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 + import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 11 + import 'package:lazurite/features/lists/data/list_repository.dart'; 12 + import 'package:lazurite/features/lists/presentation/list_members_screen.dart'; 13 + import 'package:mocktail/mocktail.dart'; 14 + 15 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 16 + 17 + class MockListRepository extends Mock implements ListRepository {} 18 + 19 + void main() { 20 + late MockAuthBloc authBloc; 21 + late MockListRepository listRepository; 22 + 23 + const tokens = AuthTokens( 24 + accessToken: 'access', 25 + refreshToken: 'refresh', 26 + did: 'did:plc:me', 27 + handle: 'me.bsky.social', 28 + ); 29 + 30 + final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); 31 + 32 + final curationList = bsky_graph.ListView( 33 + uri: listUri, 34 + cid: 'cid-1', 35 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 36 + name: 'My List', 37 + purpose: const bsky_graph.ListPurpose.knownValue(data: bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist), 38 + indexedAt: DateTime.utc(2026, 3, 21), 39 + ); 40 + 41 + final member = bsky_graph.ListItemView( 42 + uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'), 43 + subject: const ProfileView(did: 'did:plc:member', handle: 'member.bsky.social', displayName: 'Alice Member'), 44 + ); 45 + 46 + setUp(() { 47 + authBloc = MockAuthBloc(); 48 + listRepository = MockListRepository(); 49 + 50 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 51 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 52 + 53 + when( 54 + () => listRepository.getList( 55 + listUri: listUri, 56 + cursor: any(named: 'cursor'), 57 + limit: any(named: 'limit'), 58 + ), 59 + ).thenAnswer((_) async => ListDetailResult(list: curationList, items: [member], cursor: null)); 60 + }); 61 + 62 + Widget buildSubject() { 63 + return MultiBlocProvider( 64 + providers: [BlocProvider<AuthBloc>.value(value: authBloc)], 65 + child: MultiRepositoryProvider( 66 + providers: [RepositoryProvider<ListRepository>.value(value: listRepository)], 67 + child: MaterialApp( 68 + home: BlocProvider( 69 + create: (_) => ListBloc(listRepository: listRepository)..add(ListRequested(listUri: listUri)), 70 + child: ListMembersScreen(listUri: listUri), 71 + ), 72 + ), 73 + ), 74 + ); 75 + } 76 + 77 + testWidgets('shows app bar with Add members title', (tester) async { 78 + await tester.pumpWidget(buildSubject()); 79 + await tester.pumpAndSettle(); 80 + 81 + expect(find.text('Add members'), findsOneWidget); 82 + }); 83 + 84 + testWidgets('shows search field', (tester) async { 85 + await tester.pumpWidget(buildSubject()); 86 + await tester.pumpAndSettle(); 87 + 88 + expect(find.byType(TextField), findsOneWidget); 89 + expect(find.text('Search for people'), findsOneWidget); 90 + }); 91 + 92 + testWidgets('shows current members', (tester) async { 93 + await tester.pumpWidget(buildSubject()); 94 + await tester.pumpAndSettle(); 95 + 96 + expect(find.text('Alice Member'), findsOneWidget); 97 + expect(find.text('@member.bsky.social'), findsOneWidget); 98 + }); 99 + 100 + testWidgets('shows CURRENT MEMBERS heading', (tester) async { 101 + await tester.pumpWidget(buildSubject()); 102 + await tester.pumpAndSettle(); 103 + 104 + expect(find.text('CURRENT MEMBERS'), findsOneWidget); 105 + }); 106 + 107 + testWidgets('shows remove buttons for each member', (tester) async { 108 + await tester.pumpWidget(buildSubject()); 109 + await tester.pumpAndSettle(); 110 + 111 + expect(find.byIcon(Icons.remove_circle_outline), findsOneWidget); 112 + }); 113 + 114 + testWidgets('shows empty state when no members', (tester) async { 115 + when( 116 + () => listRepository.getList( 117 + listUri: listUri, 118 + cursor: any(named: 'cursor'), 119 + limit: any(named: 'limit'), 120 + ), 121 + ).thenAnswer((_) async => ListDetailResult(list: curationList, items: [], cursor: null)); 122 + 123 + await tester.pumpWidget(buildSubject()); 124 + await tester.pumpAndSettle(); 125 + 126 + expect(find.text('No members yet. Search above to add people.'), findsOneWidget); 127 + }); 128 + 129 + testWidgets('search results appear when query is entered and repo returns results', (tester) async { 130 + const searchResult = ProfileViewBasic(did: 'did:plc:new', handle: 'newuser.bsky.social', displayName: 'New User'); 131 + 132 + when( 133 + () => listRepository.searchActorsTypeahead( 134 + query: 'newuser', 135 + limit: any(named: 'limit'), 136 + ), 137 + ).thenAnswer((_) async => [searchResult]); 138 + 139 + await tester.pumpWidget(buildSubject()); 140 + await tester.pumpAndSettle(); 141 + 142 + await tester.enterText(find.byType(TextField), 'newuser'); 143 + await tester.pumpAndSettle(); 144 + 145 + expect(find.text('New User'), findsOneWidget); 146 + expect(find.text('@newuser.bsky.social'), findsOneWidget); 147 + }); 148 + }
+157
test/features/lists/presentation/my_lists_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:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 + import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; 11 + import 'package:lazurite/features/lists/data/list_repository.dart'; 12 + import 'package:lazurite/features/lists/presentation/my_lists_screen.dart'; 13 + import 'package:mocktail/mocktail.dart'; 14 + 15 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 16 + 17 + class MockListRepository extends Mock implements ListRepository {} 18 + 19 + class MockMyListsCubit extends MockCubit<MyListsState> implements MyListsCubit {} 20 + 21 + void main() { 22 + late MockAuthBloc authBloc; 23 + late MockListRepository listRepository; 24 + 25 + const tokens = AuthTokens( 26 + accessToken: 'access', 27 + refreshToken: 'refresh', 28 + did: 'did:plc:me', 29 + handle: 'me.bsky.social', 30 + ); 31 + 32 + final listUri = AtUri.parse('at://did:plc:me/app.bsky.graph.list/list-1'); 33 + final modUri = AtUri.parse('at://did:plc:me/app.bsky.graph.list/mod-1'); 34 + 35 + final curationList = bsky_graph.ListView( 36 + uri: listUri, 37 + cid: 'cid-1', 38 + creator: const ProfileView(did: 'did:plc:me', handle: 'me.bsky.social'), 39 + name: 'My Feed List', 40 + purpose: const bsky_graph.ListPurpose.knownValue(data: bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist), 41 + indexedAt: DateTime.utc(2026, 3, 21), 42 + ); 43 + 44 + final moderationList = bsky_graph.ListView( 45 + uri: modUri, 46 + cid: 'cid-2', 47 + creator: const ProfileView(did: 'did:plc:me', handle: 'me.bsky.social'), 48 + name: 'My Mod List', 49 + purpose: const bsky_graph.ListPurpose.knownValue(data: bsky_graph.KnownListPurpose.appBskyGraphDefsModlist), 50 + indexedAt: DateTime.utc(2026, 3, 21), 51 + ); 52 + 53 + setUp(() { 54 + authBloc = MockAuthBloc(); 55 + listRepository = MockListRepository(); 56 + 57 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 58 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 59 + 60 + when( 61 + () => listRepository.getLists( 62 + actor: any(named: 'actor'), 63 + cursor: any(named: 'cursor'), 64 + limit: any(named: 'limit'), 65 + ), 66 + ).thenAnswer((_) async => ListsResult(lists: [curationList, moderationList])); 67 + }); 68 + 69 + Widget buildSubject() { 70 + return MultiBlocProvider( 71 + providers: [BlocProvider<AuthBloc>.value(value: authBloc)], 72 + child: MultiRepositoryProvider( 73 + providers: [RepositoryProvider<ListRepository>.value(value: listRepository)], 74 + child: const MaterialApp(home: MyListsScreen()), 75 + ), 76 + ); 77 + } 78 + 79 + testWidgets('shows loading indicator initially', (tester) async { 80 + when( 81 + () => listRepository.getLists( 82 + actor: any(named: 'actor'), 83 + cursor: any(named: 'cursor'), 84 + limit: any(named: 'limit'), 85 + ), 86 + ).thenAnswer((_) async { 87 + await Future<void>.delayed(const Duration(hours: 1)); 88 + return const ListsResult(lists: []); 89 + }); 90 + 91 + await tester.pumpWidget(buildSubject()); 92 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 93 + await tester.pump(const Duration(hours: 2)); // drain pending timers 94 + }); 95 + 96 + testWidgets('shows FEEDS and MODERATION tabs', (tester) async { 97 + await tester.pumpWidget(buildSubject()); 98 + await tester.pumpAndSettle(); 99 + 100 + expect(find.text('FEEDS'), findsOneWidget); 101 + expect(find.text('MODERATION'), findsOneWidget); 102 + }); 103 + 104 + testWidgets('shows curation lists in FEEDS tab', (tester) async { 105 + await tester.pumpWidget(buildSubject()); 106 + await tester.pumpAndSettle(); 107 + 108 + expect(find.text('My Feed List'), findsOneWidget); 109 + }); 110 + 111 + testWidgets('shows moderation lists in MODERATION tab', (tester) async { 112 + await tester.pumpWidget(buildSubject()); 113 + await tester.pumpAndSettle(); 114 + 115 + await tester.tap(find.text('MODERATION')); 116 + await tester.pumpAndSettle(); 117 + 118 + expect(find.text('My Mod List'), findsOneWidget); 119 + }); 120 + 121 + testWidgets('shows FAB for creating a new list', (tester) async { 122 + await tester.pumpWidget(buildSubject()); 123 + await tester.pumpAndSettle(); 124 + 125 + expect(find.byType(FloatingActionButton), findsOneWidget); 126 + }); 127 + 128 + testWidgets('shows empty state when no lists', (tester) async { 129 + when( 130 + () => listRepository.getLists( 131 + actor: any(named: 'actor'), 132 + cursor: any(named: 'cursor'), 133 + limit: any(named: 'limit'), 134 + ), 135 + ).thenAnswer((_) async => const ListsResult(lists: [])); 136 + 137 + await tester.pumpWidget(buildSubject()); 138 + await tester.pumpAndSettle(); 139 + 140 + expect(find.text('No lists yet'), findsOneWidget); 141 + }); 142 + 143 + testWidgets('shows error and retry button on failure', (tester) async { 144 + when( 145 + () => listRepository.getLists( 146 + actor: any(named: 'actor'), 147 + cursor: any(named: 'cursor'), 148 + limit: any(named: 'limit'), 149 + ), 150 + ).thenThrow(Exception('network error')); 151 + 152 + await tester.pumpWidget(buildSubject()); 153 + await tester.pumpAndSettle(); 154 + 155 + expect(find.text('Retry'), findsOneWidget); 156 + }); 157 + }
+167
test/features/lists/presentation/widgets/create_edit_list_dialog_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart'; 4 + 5 + void main() { 6 + Widget buildSubject({String? initialName, String? fixedPurpose}) { 7 + return MaterialApp( 8 + home: Scaffold( 9 + body: Builder( 10 + builder: (context) => TextButton( 11 + onPressed: () => showDialog<CreateEditListResult>( 12 + context: context, 13 + builder: (_) => CreateEditListDialog(initialName: initialName, fixedPurpose: fixedPurpose), 14 + ), 15 + child: const Text('Open'), 16 + ), 17 + ), 18 + ), 19 + ); 20 + } 21 + 22 + group('CreateEditListDialog', () { 23 + testWidgets('shows Create list title in creation mode', (tester) async { 24 + await tester.pumpWidget(buildSubject()); 25 + await tester.tap(find.text('Open')); 26 + await tester.pumpAndSettle(); 27 + 28 + expect(find.text('Create list'), findsOneWidget); 29 + }); 30 + 31 + testWidgets('shows Edit list title when fixedPurpose is set', (tester) async { 32 + await tester.pumpWidget( 33 + buildSubject(initialName: 'Existing List', fixedPurpose: 'app.bsky.graph.defs#curatelist'), 34 + ); 35 + await tester.tap(find.text('Open')); 36 + await tester.pumpAndSettle(); 37 + 38 + expect(find.text('Edit list'), findsOneWidget); 39 + }); 40 + 41 + testWidgets('shows name and description fields', (tester) async { 42 + await tester.pumpWidget(buildSubject()); 43 + await tester.tap(find.text('Open')); 44 + await tester.pumpAndSettle(); 45 + 46 + expect(find.widgetWithText(TextField, 'Name'), findsOneWidget); 47 + expect(find.widgetWithText(TextField, 'Description (optional)'), findsOneWidget); 48 + }); 49 + 50 + testWidgets('shows purpose selector in creation mode', (tester) async { 51 + await tester.pumpWidget(buildSubject()); 52 + await tester.tap(find.text('Open')); 53 + await tester.pumpAndSettle(); 54 + 55 + expect(find.text('Feed'), findsOneWidget); 56 + expect(find.text('Moderation'), findsOneWidget); 57 + }); 58 + 59 + testWidgets('hides purpose selector in edit mode', (tester) async { 60 + await tester.pumpWidget(buildSubject(initialName: 'Existing', fixedPurpose: 'app.bsky.graph.defs#curatelist')); 61 + await tester.tap(find.text('Open')); 62 + await tester.pumpAndSettle(); 63 + 64 + expect(find.text('Feed'), findsNothing); 65 + expect(find.text('Moderation'), findsNothing); 66 + }); 67 + 68 + testWidgets('Create button is disabled when name is empty', (tester) async { 69 + await tester.pumpWidget(buildSubject()); 70 + await tester.tap(find.text('Open')); 71 + await tester.pumpAndSettle(); 72 + 73 + final createButton = find.widgetWithText(FilledButton, 'Create'); 74 + final button = tester.widget<FilledButton>(createButton); 75 + expect(button.onPressed, isNull); 76 + }); 77 + 78 + testWidgets('Create button is enabled after name is entered', (tester) async { 79 + await tester.pumpWidget(buildSubject()); 80 + await tester.tap(find.text('Open')); 81 + await tester.pumpAndSettle(); 82 + 83 + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'My New List'); 84 + await tester.pump(); 85 + 86 + final createButton = find.widgetWithText(FilledButton, 'Create'); 87 + final button = tester.widget<FilledButton>(createButton); 88 + expect(button.onPressed, isNotNull); 89 + }); 90 + 91 + testWidgets('returns CreateEditListResult with correct data on save', (tester) async { 92 + CreateEditListResult? result; 93 + 94 + await tester.pumpWidget( 95 + MaterialApp( 96 + home: Scaffold( 97 + body: Builder( 98 + builder: (context) => TextButton( 99 + onPressed: () async { 100 + result = await showDialog<CreateEditListResult>( 101 + context: context, 102 + builder: (_) => const CreateEditListDialog(), 103 + ); 104 + }, 105 + child: const Text('Open'), 106 + ), 107 + ), 108 + ), 109 + ), 110 + ); 111 + 112 + await tester.tap(find.text('Open')); 113 + await tester.pumpAndSettle(); 114 + 115 + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'My List'); 116 + await tester.enterText(find.widgetWithText(TextField, 'Description (optional)'), 'A description'); 117 + await tester.pump(); 118 + 119 + await tester.tap(find.widgetWithText(FilledButton, 'Create')); 120 + await tester.pumpAndSettle(); 121 + 122 + expect(result, isNotNull); 123 + expect(result!.name, 'My List'); 124 + expect(result!.description, 'A description'); 125 + expect(result!.purpose, 'app.bsky.graph.defs#curatelist'); 126 + }); 127 + 128 + testWidgets('returns null on cancel', (tester) async { 129 + CreateEditListResult? result; 130 + 131 + await tester.pumpWidget( 132 + MaterialApp( 133 + home: Scaffold( 134 + body: Builder( 135 + builder: (context) => TextButton( 136 + onPressed: () async { 137 + result = await showDialog<CreateEditListResult>( 138 + context: context, 139 + builder: (_) => const CreateEditListDialog(), 140 + ); 141 + }, 142 + child: const Text('Open'), 143 + ), 144 + ), 145 + ), 146 + ), 147 + ); 148 + 149 + await tester.tap(find.text('Open')); 150 + await tester.pumpAndSettle(); 151 + 152 + await tester.tap(find.text('Cancel')); 153 + await tester.pumpAndSettle(); 154 + 155 + expect(result, isNull); 156 + }); 157 + 158 + testWidgets('populates initial values in edit mode', (tester) async { 159 + await tester.pumpWidget(buildSubject(initialName: 'Existing List', fixedPurpose: 'app.bsky.graph.defs#modlist')); 160 + await tester.tap(find.text('Open')); 161 + await tester.pumpAndSettle(); 162 + 163 + final nameField = tester.widget<TextField>(find.widgetWithText(TextField, 'Existing List')); 164 + expect(nameField.controller?.text, 'Existing List'); 165 + }); 166 + }); 167 + }
+90
test/features/profile/presentation/profile_screen_test.dart
··· 20 20 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 21 21 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 22 22 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 23 + import 'package:lazurite/features/lists/data/list_repository.dart'; 23 24 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 24 25 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 25 26 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; ··· 41 42 class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} 42 43 43 44 class MockPostActionCache extends Mock implements PostActionCache {} 45 + 46 + class MockListRepository extends Mock implements ListRepository {} 44 47 45 48 void main() { 46 49 late MockAuthBloc authBloc; ··· 461 464 verifyNever(() => feedBloc.add(const FeedRefreshRequested())); 462 465 463 466 await streamCtrl.close(); 467 + }); 468 + }); 469 + 470 + group('Lists tab', () { 471 + late MockListRepository listRepository; 472 + 473 + setUp(() { 474 + listRepository = MockListRepository(); 475 + when( 476 + () => listRepository.getLists( 477 + actor: any(named: 'actor'), 478 + cursor: any(named: 'cursor'), 479 + limit: any(named: 'limit'), 480 + ), 481 + ).thenAnswer((_) async => const ListsResult(lists: [])); 482 + }); 483 + 484 + testWidgets('shows LISTS tab label', (tester) async { 485 + useLargeScreen(tester); 486 + await tester.pumpWidget(buildSubject()); 487 + 488 + expect(find.text('LISTS'), findsOneWidget); 489 + }); 490 + 491 + testWidgets('shows empty state when navigating to LISTS tab', (tester) async { 492 + useLargeScreen(tester); 493 + 494 + await tester.pumpWidget( 495 + MultiBlocProvider( 496 + providers: [ 497 + BlocProvider<AuthBloc>.value(value: authBloc), 498 + BlocProvider<ProfileBloc>.value(value: profileBloc), 499 + BlocProvider<FeedBloc>.value(value: feedBloc), 500 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 501 + ], 502 + child: MultiRepositoryProvider( 503 + providers: [RepositoryProvider<ListRepository>.value(value: listRepository)], 504 + child: const MaterialApp(home: ProfileScreen()), 505 + ), 506 + ), 507 + ); 508 + 509 + await tester.tap(find.text('LISTS')); 510 + await tester.pumpAndSettle(); 511 + 512 + expect(find.text('No lists yet'), findsOneWidget); 513 + }); 514 + }); 515 + 516 + group('Add to list', () { 517 + testWidgets('overflow menu shows Add to list option for other profiles', (tester) async { 518 + useLargeScreen(tester); 519 + const otherProfile = ProfileViewDetailed( 520 + did: 'did:plc:other', 521 + handle: 'other.bsky.social', 522 + displayName: 'Other User', 523 + ); 524 + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); 525 + whenListen( 526 + profileBloc, 527 + const Stream<ProfileState>.empty(), 528 + initialState: const ProfileState.loaded(profile: otherProfile), 529 + ); 530 + 531 + final mockProfileActionRepository = MockProfileActionRepository(); 532 + 533 + await tester.pumpWidget( 534 + MultiRepositoryProvider( 535 + providers: [RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository)], 536 + child: MultiBlocProvider( 537 + providers: [ 538 + BlocProvider<AuthBloc>.value(value: authBloc), 539 + BlocProvider<ProfileBloc>.value(value: profileBloc), 540 + BlocProvider<FeedBloc>.value(value: feedBloc), 541 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 542 + ], 543 + child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), 544 + ), 545 + ), 546 + ); 547 + 548 + await tester.pumpAndSettle(); 549 + 550 + await tester.tap(find.byIcon(Icons.more_vert)); 551 + await tester.pumpAndSettle(); 552 + 553 + expect(find.text('Add to list'), findsOneWidget); 464 554 }); 465 555 }); 466 556 }