mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: starter pack screens, detail view, and follow all functionality

+1094 -8
+6 -6
docs/tasks/phase-4.md
··· 80 80 81 81 ### Viewing 82 82 83 - - [ ] Starter pack detail screen — name, description, creator, join stats, member sample (up to 12), recommended feeds (up to 3) 84 - - [ ] "See all members" — navigate to full member list via backing reference list 85 - - [ ] "Follow all" button — follow every member in the pack 86 - - [ ] Actor starter packs screen — paginated list via `getActorStarterPacks` 83 + - [x] Starter pack detail screen — name, description, creator, join stats, member sample (up to 12), recommended feeds (up to 3) 84 + - [x] "See all members" — navigate to full member list via backing reference list 85 + - [x] "Follow all" button — follow every member in the pack 86 + - [x] Actor starter packs screen — paginated list via `getActorStarterPacks` 87 87 88 88 ### Creation & Editing 89 89 ··· 94 94 95 95 ### Profile Integration 96 96 97 - - [ ] "Starter Packs" section on profile screens showing packs created by actor 98 - - [ ] Starter pack cards — name, creator, member count, join stats 97 + - [x] "Starter Packs" section on profile screens showing packs created by actor 98 + - [x] Starter pack cards — name, creator, member count, join stats
+17
lib/core/router/app_router.dart
··· 40 40 import 'package:lazurite/features/lists/presentation/my_lists_screen.dart'; 41 41 import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; 42 42 import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; 43 + import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; 44 + import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 43 45 import 'package:lazurite/features/settings/presentation/about_screen.dart'; 44 46 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 45 47 ··· 130 132 builder: (context, state) => SavedPostsScreen(accountDid: context.read<String>()), 131 133 ), 132 134 GoRoute(path: '/lists', builder: (context, state) => const MyListsScreen()), 135 + GoRoute( 136 + path: '/starter-pack', 137 + builder: (context, state) { 138 + final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); 139 + final packUri = AtUri.parse(uriStr); 140 + return StarterPackDetailScreen(packUri: packUri); 141 + }, 142 + ), 143 + GoRoute( 144 + path: '/starter-packs', 145 + builder: (context, state) { 146 + final actor = state.uri.queryParameters['actor'] ?? ''; 147 + return ActorStarterPacksScreen(actor: actor); 148 + }, 149 + ), 133 150 GoRoute( 134 151 path: '/list', 135 152 builder: (context, state) {
+100 -1
lib/features/profile/presentation/profile_screen.dart
··· 17 17 import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; 18 18 import 'package:lazurite/features/lists/data/list_repository.dart'; 19 19 import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; 20 + import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 21 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 22 + import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 20 23 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 21 24 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 22 25 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; ··· 69 72 (label: 'Media', filter: FeedFilter.postsWithMedia), 70 73 ]; 71 74 72 - static const _tabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS']; 75 + static const _tabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS', 'PACKS']; 73 76 74 77 late final TabController _tabController; 75 78 ··· 189 192 children: [ 190 193 for (var i = 0; i < _feedTabs.length; i++) _buildFeedList(feedState, _feedTabs[i].filter, profile), 191 194 _buildListsTab(context, profile), 195 + _buildStarterPacksTab(context, profile), 192 196 ], 193 197 ), 194 198 ); ··· 719 723 return _ProfileListsPane(actor: actor, listRepository: listRepository); 720 724 } 721 725 726 + Widget _buildStarterPacksTab(BuildContext context, ProfileViewDetailed? profile) { 727 + final actor = profile?.did ?? _resolvedActor; 728 + if (actor == null) return const SizedBox.shrink(); 729 + 730 + StarterPackRepository? starterPackRepository; 731 + try { 732 + starterPackRepository = context.read<StarterPackRepository>(); 733 + } catch (_) { 734 + return const SizedBox.shrink(); 735 + } 736 + 737 + return _ProfileStarterPacksPane(actor: actor, starterPackRepository: starterPackRepository); 738 + } 739 + 722 740 String _emptyLabel(FeedFilter filter) { 723 741 switch (filter) { 724 742 case FeedFilter.postsNoReplies: ··· 747 765 final uri = Uri.tryParse(website.startsWith('http') ? website : 'https://$website'); 748 766 if (uri == null) return; 749 767 await launchUrl(uri, mode: LaunchMode.externalApplication); 768 + } 769 + } 770 + 771 + /// Pane that loads and displays starter packs for a given [actor] within the profile screen. 772 + class _ProfileStarterPacksPane extends StatefulWidget { 773 + const _ProfileStarterPacksPane({required this.actor, required this.starterPackRepository}); 774 + 775 + final String actor; 776 + final StarterPackRepository starterPackRepository; 777 + 778 + @override 779 + State<_ProfileStarterPacksPane> createState() => _ProfileStarterPacksPaneState(); 780 + } 781 + 782 + class _ProfileStarterPacksPaneState extends State<_ProfileStarterPacksPane> { 783 + late final ActorStarterPacksCubit _cubit; 784 + 785 + @override 786 + void initState() { 787 + super.initState(); 788 + _cubit = ActorStarterPacksCubit(starterPackRepository: widget.starterPackRepository)..load(actor: widget.actor); 789 + } 790 + 791 + @override 792 + void didUpdateWidget(_ProfileStarterPacksPane oldWidget) { 793 + super.didUpdateWidget(oldWidget); 794 + if (oldWidget.actor != widget.actor) { 795 + _cubit.load(actor: widget.actor); 796 + } 797 + } 798 + 799 + @override 800 + void dispose() { 801 + _cubit.close(); 802 + super.dispose(); 803 + } 804 + 805 + @override 806 + Widget build(BuildContext context) { 807 + return BlocBuilder<ActorStarterPacksCubit, ActorStarterPacksState>( 808 + bloc: _cubit, 809 + builder: (context, state) { 810 + if (state.status == ActorStarterPacksStatus.loading) { 811 + return const Center(child: CircularProgressIndicator()); 812 + } 813 + 814 + if (state.status == ActorStarterPacksStatus.error) { 815 + return Center( 816 + child: Column( 817 + mainAxisSize: MainAxisSize.min, 818 + children: [ 819 + Text(state.errorMessage ?? 'Failed to load starter packs'), 820 + const SizedBox(height: 12), 821 + FilledButton( 822 + onPressed: () => _cubit.load(actor: widget.actor), 823 + child: const Text('Retry'), 824 + ), 825 + ], 826 + ), 827 + ); 828 + } 829 + 830 + if (state.starterPacks.isEmpty) { 831 + return const Center(child: Text('No starter packs yet')); 832 + } 833 + 834 + return RefreshIndicator( 835 + onRefresh: _cubit.refresh, 836 + child: ListView.builder( 837 + padding: const EdgeInsets.symmetric(vertical: 8), 838 + itemCount: state.starterPacks.length, 839 + itemBuilder: (context, index) => StarterPackCard( 840 + key: ValueKey(state.starterPacks[index].uri), 841 + pack: state.starterPacks[index], 842 + onTap: () => 843 + context.push('/starter-pack?uri=${Uri.encodeComponent(state.starterPacks[index].uri.toString())}'), 844 + ), 845 + ), 846 + ); 847 + }, 848 + ); 750 849 } 751 850 } 752 851
+18
lib/features/starter_packs/bloc/starter_pack_bloc.dart
··· 17 17 on<StarterPackDeleted>(_onStarterPackDeleted); 18 18 on<MemberAdded>(_onMemberAdded); 19 19 on<MemberRemoved>(_onMemberRemoved); 20 + on<FollowAllRequested>(_onFollowAllRequested); 20 21 } 21 22 22 23 final StarterPackRepository _starterPackRepository; ··· 130 131 action: () => _starterPackRepository.removeMember(listItemUri: event.listItemUri), 131 132 errorPrefix: 'Failed to remove member', 132 133 ); 134 + } 135 + 136 + Future<void> _onFollowAllRequested(FollowAllRequested event, Emitter<StarterPackState> emit) async { 137 + final refListUri = state.starterPack?.list?.uri; 138 + 139 + if (state.status != StarterPackStatus.loaded || refListUri == null || state.isFollowingAll) { 140 + return; 141 + } 142 + 143 + emit(state.copyWith(isFollowingAll: true, errorMessage: null, followedCount: null)); 144 + 145 + try { 146 + final count = await _starterPackRepository.followAll(referenceListUri: refListUri); 147 + emit(state.copyWith(isFollowingAll: false, followedCount: count)); 148 + } catch (error) { 149 + emit(state.copyWith(isFollowingAll: false, errorMessage: 'Failed to follow members: $error')); 150 + } 133 151 } 134 152 135 153 Future<void> _runMutation(
+4
lib/features/starter_packs/bloc/starter_pack_event.dart
··· 72 72 @override 73 73 List<Object?> get props => [listItemUri]; 74 74 } 75 + 76 + final class FollowAllRequested extends StarterPackEvent { 77 + const FollowAllRequested(); 78 + }
+22 -1
lib/features/starter_packs/bloc/starter_pack_state.dart
··· 9 9 this.starterPack, 10 10 this.isRefreshing = false, 11 11 this.isMutating = false, 12 + this.isFollowingAll = false, 13 + this.followedCount, 12 14 this.errorMessage, 13 15 }); 14 16 ··· 21 23 required StarterPackView starterPack, 22 24 bool isRefreshing = false, 23 25 bool isMutating = false, 26 + bool isFollowingAll = false, 27 + int? followedCount, 24 28 String? errorMessage, 25 29 }) : this._( 26 30 status: StarterPackStatus.loaded, ··· 28 32 starterPack: starterPack, 29 33 isRefreshing: isRefreshing, 30 34 isMutating: isMutating, 35 + isFollowingAll: isFollowingAll, 36 + followedCount: followedCount, 31 37 errorMessage: errorMessage, 32 38 ); 33 39 ··· 41 47 final StarterPackView? starterPack; 42 48 final bool isRefreshing; 43 49 final bool isMutating; 50 + final bool isFollowingAll; 51 + final int? followedCount; 44 52 final String? errorMessage; 45 53 46 54 bool get isLoading => status == StarterPackStatus.loading; ··· 52 60 Object? starterPack = _starterPackNoValue, 53 61 bool? isRefreshing, 54 62 bool? isMutating, 63 + bool? isFollowingAll, 64 + Object? followedCount = _starterPackNoValue, 55 65 Object? errorMessage = _starterPackNoValue, 56 66 }) { 57 67 return StarterPackState._( ··· 60 70 starterPack: starterPack == _starterPackNoValue ? this.starterPack : starterPack as StarterPackView?, 61 71 isRefreshing: isRefreshing ?? this.isRefreshing, 62 72 isMutating: isMutating ?? this.isMutating, 73 + isFollowingAll: isFollowingAll ?? this.isFollowingAll, 74 + followedCount: followedCount == _starterPackNoValue ? this.followedCount : followedCount as int?, 63 75 errorMessage: errorMessage == _starterPackNoValue ? this.errorMessage : errorMessage as String?, 64 76 ); 65 77 } 66 78 67 79 @override 68 - List<Object?> get props => [status, packUri, starterPack, isRefreshing, isMutating, errorMessage]; 80 + List<Object?> get props => [ 81 + status, 82 + packUri, 83 + starterPack, 84 + isRefreshing, 85 + isMutating, 86 + isFollowingAll, 87 + followedCount, 88 + errorMessage, 89 + ]; 69 90 } 70 91 71 92 const _starterPackNoValue = Object();
+26
lib/features/starter_packs/data/starter_pack_repository.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart' show AtUri; 2 2 import 'package:bluesky/app_bsky_graph_defs.dart'; 3 3 import 'package:bluesky/app_bsky_graph_starterpack.dart'; 4 + import 'package:lazurite/core/logging/app_logger.dart'; 4 5 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 5 6 6 7 class StarterPackRepository { ··· 105 106 106 107 Future<void> removeMember({required AtUri listItemUri}) async { 107 108 await _bluesky.graph.listitem.delete(rkey: listItemUri.rkey); 109 + } 110 + 111 + /// Follows every member in the starter pack's backing reference list. 112 + /// Paginates through all list items and calls follow.create for each. 113 + /// Returns the number of members followed. 114 + Future<int> followAll({required AtUri referenceListUri}) async { 115 + int count = 0; 116 + String? cursor; 117 + 118 + do { 119 + final response = await _bluesky.graph.getList(list: referenceListUri, cursor: cursor, limit: 100); 120 + 121 + for (final item in response.data.items as List) { 122 + try { 123 + await _bluesky.graph.follow.create(subject: item.subject.did as String, createdAt: DateTime.now()); 124 + count++; 125 + } catch (_) { 126 + log.w('Failed to follow ${item.subject.did} (already followed or blocked)'); 127 + } 128 + } 129 + 130 + cursor = response.data.cursor as String?; 131 + } while (cursor != null); 132 + 133 + return count; 108 134 } 109 135 110 136 Future<AtUri> _createReferenceList({required String userDid}) async {
+94
lib/features/starter_packs/presentation/actor_starter_packs_screen.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 5 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 6 + import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 7 + 8 + class ActorStarterPacksScreen extends StatelessWidget { 9 + const ActorStarterPacksScreen({super.key, required this.actor}); 10 + 11 + final String actor; 12 + 13 + @override 14 + Widget build(BuildContext context) { 15 + return BlocProvider( 16 + create: (context) => 17 + ActorStarterPacksCubit(starterPackRepository: context.read<StarterPackRepository>())..load(actor: actor), 18 + child: _ActorStarterPacksView(actor: actor), 19 + ); 20 + } 21 + } 22 + 23 + class _ActorStarterPacksView extends StatelessWidget { 24 + const _ActorStarterPacksView({required this.actor}); 25 + 26 + final String actor; 27 + 28 + @override 29 + Widget build(BuildContext context) { 30 + return Scaffold( 31 + appBar: AppBar(title: const Text('Starter Packs')), 32 + body: BlocBuilder<ActorStarterPacksCubit, ActorStarterPacksState>( 33 + builder: (context, state) { 34 + if (state.status == ActorStarterPacksStatus.loading) { 35 + return const Center(child: CircularProgressIndicator()); 36 + } 37 + 38 + if (state.status == ActorStarterPacksStatus.error) { 39 + return Center( 40 + child: Column( 41 + mainAxisSize: MainAxisSize.min, 42 + children: [ 43 + Text(state.errorMessage ?? 'Failed to load starter packs'), 44 + const SizedBox(height: 12), 45 + FilledButton( 46 + onPressed: () => context.read<ActorStarterPacksCubit>().load(actor: actor), 47 + child: const Text('Retry'), 48 + ), 49 + ], 50 + ), 51 + ); 52 + } 53 + 54 + if (state.starterPacks.isEmpty) { 55 + return const Center(child: Text('No starter packs yet')); 56 + } 57 + 58 + return RefreshIndicator( 59 + onRefresh: () => context.read<ActorStarterPacksCubit>().refresh(), 60 + child: NotificationListener<ScrollNotification>( 61 + onNotification: (notification) { 62 + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 && 63 + state.hasMore && 64 + !state.isLoadingMore) { 65 + context.read<ActorStarterPacksCubit>().loadMore(); 66 + } 67 + return false; 68 + }, 69 + child: ListView.builder( 70 + padding: const EdgeInsets.symmetric(vertical: 8), 71 + itemCount: state.starterPacks.length + (state.isLoadingMore ? 1 : 0), 72 + itemBuilder: (context, index) { 73 + if (index >= state.starterPacks.length) { 74 + return const Padding( 75 + padding: EdgeInsets.all(16), 76 + child: Center(child: CircularProgressIndicator()), 77 + ); 78 + } 79 + 80 + final pack = state.starterPacks[index]; 81 + return StarterPackCard( 82 + key: ValueKey(pack.uri), 83 + pack: pack, 84 + onTap: () => context.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'), 85 + ); 86 + }, 87 + ), 88 + ), 89 + ); 90 + }, 91 + ), 92 + ); 93 + } 94 + }
+339
lib/features/starter_packs/presentation/starter_pack_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:flutter/material.dart' hide ListView; 4 + import 'package:flutter/material.dart' as material show ListView; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 8 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 9 + 10 + class StarterPackDetailScreen extends StatelessWidget { 11 + const StarterPackDetailScreen({super.key, required this.packUri}); 12 + 13 + final AtUri packUri; 14 + 15 + @override 16 + Widget build(BuildContext context) { 17 + return BlocProvider( 18 + create: (context) => 19 + StarterPackBloc(starterPackRepository: context.read<StarterPackRepository>()) 20 + ..add(StarterPackRequested(starterPackUri: packUri)), 21 + child: const _StarterPackDetailView(), 22 + ); 23 + } 24 + } 25 + 26 + class _StarterPackDetailView extends StatelessWidget { 27 + const _StarterPackDetailView(); 28 + 29 + @override 30 + Widget build(BuildContext context) { 31 + return BlocConsumer<StarterPackBloc, StarterPackState>( 32 + listenWhen: (prev, curr) => 33 + (prev.followedCount == null && curr.followedCount != null) || 34 + (prev.errorMessage != curr.errorMessage && curr.errorMessage != null && !curr.isLoading) || 35 + curr.status == StarterPackStatus.deleted, 36 + listener: (context, state) { 37 + if (state.status == StarterPackStatus.deleted) { 38 + if (context.canPop()) { 39 + context.pop(); 40 + } else { 41 + context.go('/profile'); 42 + } 43 + return; 44 + } 45 + 46 + if (state.followedCount != null) { 47 + ScaffoldMessenger.of(context).showSnackBar( 48 + SnackBar( 49 + content: Text('Followed ${state.followedCount} member${state.followedCount == 1 ? '' : 's'}'), 50 + behavior: SnackBarBehavior.floating, 51 + ), 52 + ); 53 + } else if (state.errorMessage != null) { 54 + ScaffoldMessenger.of( 55 + context, 56 + ).showSnackBar(SnackBar(content: Text(state.errorMessage!), behavior: SnackBarBehavior.floating)); 57 + } 58 + }, 59 + builder: (context, state) { 60 + final pack = state.starterPack; 61 + 62 + return Scaffold( 63 + body: CustomScrollView( 64 + slivers: [ 65 + SliverAppBar( 66 + floating: true, 67 + pinned: true, 68 + snap: true, 69 + title: Text(pack != null ? ((pack.record['name'] as String?) ?? 'Starter Pack') : 'Starter Pack'), 70 + actions: [ 71 + if (state.isMutating) 72 + const Padding( 73 + padding: EdgeInsets.symmetric(horizontal: 16), 74 + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), 75 + ), 76 + ], 77 + ), 78 + if (state.isLoading) 79 + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) 80 + else if (state.status == StarterPackStatus.error && pack == null) 81 + SliverFillRemaining( 82 + child: Center( 83 + child: Padding( 84 + padding: const EdgeInsets.all(24), 85 + child: Column( 86 + mainAxisSize: MainAxisSize.min, 87 + children: [ 88 + Text(state.errorMessage ?? 'Failed to load starter pack'), 89 + const SizedBox(height: 12), 90 + FilledButton( 91 + onPressed: () => context.read<StarterPackBloc>().add( 92 + StarterPackRequested(starterPackUri: state.packUri!), 93 + ), 94 + child: const Text('Retry'), 95 + ), 96 + ], 97 + ), 98 + ), 99 + ), 100 + ) 101 + else if (pack != null) 102 + SliverToBoxAdapter( 103 + child: _StarterPackContent(pack: pack, state: state), 104 + ), 105 + ], 106 + ), 107 + ); 108 + }, 109 + ); 110 + } 111 + } 112 + 113 + class _StarterPackContent extends StatelessWidget { 114 + const _StarterPackContent({required this.pack, required this.state}); 115 + 116 + final bsky_graph.StarterPackView pack; 117 + final StarterPackState state; 118 + 119 + @override 120 + Widget build(BuildContext context) { 121 + final colorScheme = Theme.of(context).colorScheme; 122 + final textTheme = Theme.of(context).textTheme; 123 + 124 + final name = (pack.record['name'] as String?) ?? 'Starter Pack'; 125 + final description = pack.record['description'] as String?; 126 + 127 + return Padding( 128 + padding: const EdgeInsets.all(16), 129 + child: Column( 130 + crossAxisAlignment: CrossAxisAlignment.start, 131 + children: [ 132 + GestureDetector( 133 + onTap: () => context.push('/profile/view?actor=${pack.creator.did}'), 134 + child: Row( 135 + children: [ 136 + CircleAvatar( 137 + radius: 20, 138 + backgroundColor: colorScheme.surfaceContainerHighest, 139 + backgroundImage: pack.creator.avatar != null ? NetworkImage(pack.creator.avatar!) : null, 140 + child: pack.creator.avatar == null 141 + ? Text( 142 + (pack.creator.displayName?.isNotEmpty == true 143 + ? pack.creator.displayName! 144 + : pack.creator.handle) 145 + .substring(0, 1) 146 + .toUpperCase(), 147 + ) 148 + : null, 149 + ), 150 + const SizedBox(width: 12), 151 + Column( 152 + crossAxisAlignment: CrossAxisAlignment.start, 153 + children: [ 154 + Text( 155 + pack.creator.displayName ?? pack.creator.handle, 156 + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), 157 + ), 158 + Text( 159 + '@${pack.creator.handle}', 160 + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 161 + ), 162 + ], 163 + ), 164 + ], 165 + ), 166 + ), 167 + const SizedBox(height: 16), 168 + Text(name, style: textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)), 169 + if (description?.isNotEmpty ?? false) ...[ 170 + const SizedBox(height: 8), 171 + Text(description!, style: textTheme.bodyMedium), 172 + ], 173 + const SizedBox(height: 16), 174 + if (pack.joinedWeekCount != null || pack.joinedAllTimeCount != null) ...[ 175 + Row( 176 + children: [ 177 + if (pack.joinedWeekCount != null) ...[ 178 + _buildStatChip(context, pack.joinedWeekCount!, 'joined this week'), 179 + const SizedBox(width: 12), 180 + ], 181 + if (pack.joinedAllTimeCount != null) _buildStatChip(context, pack.joinedAllTimeCount!, 'total joined'), 182 + ], 183 + ), 184 + const SizedBox(height: 16), 185 + ], 186 + const Divider(), 187 + const SizedBox(height: 8), 188 + _buildMembersSection(context), 189 + if (pack.feeds?.isNotEmpty ?? false) ...[ 190 + const SizedBox(height: 16), 191 + const Divider(), 192 + const SizedBox(height: 8), 193 + _buildFeedsSection(context), 194 + ], 195 + const SizedBox(height: 24), 196 + ], 197 + ), 198 + ); 199 + } 200 + 201 + Widget _buildStatChip(BuildContext context, int count, String label) { 202 + final theme = Theme.of(context); 203 + final colorScheme = theme.colorScheme; 204 + final textTheme = theme.textTheme; 205 + return Container( 206 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 207 + decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), 208 + child: Column( 209 + children: [ 210 + Text(_formatCount(count), style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 211 + Text(label, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), 212 + ], 213 + ), 214 + ); 215 + } 216 + 217 + Widget _buildMembersSection(BuildContext context) { 218 + final colorScheme = Theme.of(context).colorScheme; 219 + final textTheme = Theme.of(context).textTheme; 220 + final sample = pack.listItemsSample ?? const []; 221 + final refListUri = pack.list?.uri; 222 + 223 + return Column( 224 + crossAxisAlignment: CrossAxisAlignment.start, 225 + children: [ 226 + Row( 227 + children: [ 228 + Text('Members', style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 229 + if (refListUri != null) ...[ 230 + const Spacer(), 231 + TextButton( 232 + onPressed: () => context.push('/list?uri=${Uri.encodeComponent(refListUri.toString())}'), 233 + child: const Text('See all'), 234 + ), 235 + ], 236 + ], 237 + ), 238 + const SizedBox(height: 8), 239 + if (sample.isEmpty) 240 + Text('No members', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)) 241 + else 242 + SizedBox( 243 + height: 72, 244 + child: material.ListView.builder( 245 + scrollDirection: Axis.horizontal, 246 + itemCount: sample.length, 247 + itemBuilder: (context, index) { 248 + final member = sample[index]; 249 + return GestureDetector( 250 + onTap: () => context.push('/profile/view?actor=${member.subject.did}'), 251 + child: Padding( 252 + padding: const EdgeInsets.only(right: 12), 253 + child: Column( 254 + children: [ 255 + CircleAvatar( 256 + radius: 24, 257 + backgroundColor: colorScheme.surfaceContainerHighest, 258 + backgroundImage: member.subject.avatar != null ? NetworkImage(member.subject.avatar!) : null, 259 + child: member.subject.avatar == null 260 + ? Text( 261 + (member.subject.displayName?.isNotEmpty == true 262 + ? member.subject.displayName! 263 + : member.subject.handle) 264 + .substring(0, 1) 265 + .toUpperCase(), 266 + ) 267 + : null, 268 + ), 269 + const SizedBox(height: 4), 270 + SizedBox( 271 + width: 56, 272 + child: Text( 273 + member.subject.displayName ?? member.subject.handle, 274 + style: textTheme.labelSmall, 275 + maxLines: 1, 276 + overflow: TextOverflow.ellipsis, 277 + textAlign: TextAlign.center, 278 + ), 279 + ), 280 + ], 281 + ), 282 + ), 283 + ); 284 + }, 285 + ), 286 + ), 287 + const SizedBox(height: 16), 288 + if (refListUri != null) 289 + SizedBox( 290 + width: double.infinity, 291 + child: FilledButton.icon( 292 + onPressed: state.isFollowingAll 293 + ? null 294 + : () => context.read<StarterPackBloc>().add(const FollowAllRequested()), 295 + icon: state.isFollowingAll 296 + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) 297 + : const Icon(Icons.group_add_outlined), 298 + label: Text(state.isFollowingAll ? 'Following…' : 'Follow all'), 299 + ), 300 + ), 301 + ], 302 + ); 303 + } 304 + 305 + Widget _buildFeedsSection(BuildContext context) { 306 + final theme = Theme.of(context); 307 + final colorScheme = theme.colorScheme; 308 + final textTheme = theme.textTheme; 309 + final feeds = pack.feeds ?? const []; 310 + 311 + return Column( 312 + crossAxisAlignment: CrossAxisAlignment.start, 313 + children: [ 314 + Text('Recommended Feeds', style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 315 + const SizedBox(height: 8), 316 + for (final feed in feeds.take(3)) 317 + ListTile( 318 + contentPadding: EdgeInsets.zero, 319 + leading: CircleAvatar( 320 + radius: 20, 321 + backgroundColor: colorScheme.surfaceContainerHighest, 322 + backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, 323 + child: feed.avatar == null ? const Icon(Icons.rss_feed) : null, 324 + ), 325 + title: Text(feed.displayName, style: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)), 326 + subtitle: feed.description != null 327 + ? Text(feed.description!, maxLines: 1, overflow: TextOverflow.ellipsis) 328 + : null, 329 + ), 330 + ], 331 + ); 332 + } 333 + 334 + String _formatCount(int count) { 335 + if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; 336 + if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; 337 + return '$count'; 338 + } 339 + }
+94
lib/features/starter_packs/presentation/widgets/starter_pack_card.dart
··· 1 + import 'package:bluesky/app_bsky_graph_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + 4 + class StarterPackCard extends StatelessWidget { 5 + const StarterPackCard({super.key, required this.pack, this.onTap}); 6 + 7 + final StarterPackViewBasic pack; 8 + final VoidCallback? onTap; 9 + 10 + @override 11 + Widget build(BuildContext context) { 12 + final colorScheme = Theme.of(context).colorScheme; 13 + final textTheme = Theme.of(context).textTheme; 14 + 15 + final name = (pack.record['name'] as String?) ?? 'Starter Pack'; 16 + final memberCount = pack.listItemCount; 17 + final joinedWeek = pack.joinedWeekCount; 18 + final joinedAll = pack.joinedAllTimeCount; 19 + 20 + return Card( 21 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 22 + child: InkWell( 23 + onTap: onTap, 24 + borderRadius: BorderRadius.circular(12), 25 + child: Padding( 26 + padding: const EdgeInsets.all(16), 27 + child: Column( 28 + crossAxisAlignment: CrossAxisAlignment.start, 29 + children: [ 30 + Row( 31 + children: [ 32 + CircleAvatar( 33 + radius: 20, 34 + backgroundColor: colorScheme.primaryContainer, 35 + child: Icon(Icons.group_outlined, color: colorScheme.onPrimaryContainer, size: 20), 36 + ), 37 + const SizedBox(width: 12), 38 + Expanded( 39 + child: Column( 40 + crossAxisAlignment: CrossAxisAlignment.start, 41 + children: [ 42 + Text( 43 + name, 44 + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), 45 + maxLines: 1, 46 + overflow: TextOverflow.ellipsis, 47 + ), 48 + Text( 49 + 'by @${pack.creator.handle}', 50 + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 51 + ), 52 + ], 53 + ), 54 + ), 55 + const Icon(Icons.chevron_right), 56 + ], 57 + ), 58 + const SizedBox(height: 12), 59 + Wrap( 60 + spacing: 16, 61 + children: [ 62 + if (memberCount != null) _buildStat(context, memberCount, 'members'), 63 + if (joinedWeek != null) _buildStat(context, joinedWeek, 'joined this week'), 64 + if (joinedAll != null) _buildStat(context, joinedAll, 'joined total'), 65 + ], 66 + ), 67 + ], 68 + ), 69 + ), 70 + ), 71 + ); 72 + } 73 + 74 + Widget _buildStat(BuildContext context, int count, String label) { 75 + return Column( 76 + crossAxisAlignment: CrossAxisAlignment.start, 77 + children: [ 78 + Text(_formatCount(count), style: Theme.of(context).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700)), 79 + Text( 80 + label, 81 + style: Theme.of( 82 + context, 83 + ).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 84 + ), 85 + ], 86 + ); 87 + } 88 + 89 + String _formatCount(int count) { 90 + if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; 91 + if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; 92 + return '$count'; 93 + } 94 + }
+5
lib/main.dart
··· 24 24 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 25 25 import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 26 26 import 'package:lazurite/features/lists/data/list_repository.dart'; 27 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 27 28 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 28 29 import 'package:lazurite/features/messages/data/convo_repository.dart'; 29 30 import 'package:lazurite/features/moderation/data/moderation_service.dart'; ··· 176 177 RepositoryProvider( 177 178 create: (context) => 178 179 PostThreadRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 180 + ), 181 + RepositoryProvider( 182 + create: (context) => 183 + StarterPackRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 179 184 ), 180 185 RepositoryProvider(create: (_) => PostActionRepository(bluesky: bluesky)), 181 186 RepositoryProvider(create: (_) => ProfileActionRepository(bluesky: bluesky)),
+59
test/features/starter_packs/bloc/starter_pack_bloc_test.dart
··· 389 389 act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), 390 390 expect: () => [], 391 391 ); 392 + 393 + blocTest<StarterPackBloc, StarterPackState>( 394 + 'follows all members and emits followedCount', 395 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 396 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 397 + setUp: () { 398 + when(() => mockRepository.followAll(referenceListUri: refListUri)).thenAnswer((_) async => 5); 399 + }, 400 + act: (bloc) => bloc.add(const FollowAllRequested()), 401 + expect: () => [ 402 + predicate<StarterPackState>((state) => state.isFollowingAll && state.followedCount == null), 403 + predicate<StarterPackState>((state) => !state.isFollowingAll && state.followedCount == 5), 404 + ], 405 + verify: (_) { 406 + verify(() => mockRepository.followAll(referenceListUri: refListUri)).called(1); 407 + }, 408 + ); 409 + 410 + blocTest<StarterPackBloc, StarterPackState>( 411 + 'FollowAllRequested emits error when followAll fails', 412 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 413 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), 414 + setUp: () { 415 + when( 416 + () => mockRepository.followAll(referenceListUri: any(named: 'referenceListUri')), 417 + ).thenThrow(Exception('network error')); 418 + }, 419 + act: (bloc) => bloc.add(const FollowAllRequested()), 420 + expect: () => [ 421 + predicate<StarterPackState>((state) => state.isFollowingAll && state.errorMessage == null), 422 + predicate<StarterPackState>((state) => !state.isFollowingAll && state.errorMessage != null), 423 + ], 424 + ); 425 + 426 + blocTest<StarterPackBloc, StarterPackState>( 427 + 'FollowAllRequested is a no-op when not loaded', 428 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 429 + act: (bloc) => bloc.add(const FollowAllRequested()), 430 + expect: () => [], 431 + ); 432 + 433 + blocTest<StarterPackBloc, StarterPackState>( 434 + 'FollowAllRequested is a no-op when ref list is missing', 435 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 436 + seed: () => StarterPackState.loaded( 437 + packUri: packUri, 438 + starterPack: _buildStarterPackView(packUri: packUri, refListUri: null), 439 + ), 440 + act: (bloc) => bloc.add(const FollowAllRequested()), 441 + expect: () => [], 442 + ); 443 + 444 + blocTest<StarterPackBloc, StarterPackState>( 445 + 'FollowAllRequested is a no-op when already following all', 446 + build: () => StarterPackBloc(starterPackRepository: mockRepository), 447 + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack, isFollowingAll: true), 448 + act: (bloc) => bloc.add(const FollowAllRequested()), 449 + expect: () => [], 450 + ); 392 451 }); 393 452 } 394 453
+128
test/features/starter_packs/presentation/actor_starter_packs_screen_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_graph_defs.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 8 + import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockStarterPackRepository extends Mock implements StarterPackRepository {} 12 + 13 + void main() { 14 + late MockStarterPackRepository mockRepository; 15 + 16 + const actor = 'did:plc:creator'; 17 + 18 + setUp(() { 19 + mockRepository = MockStarterPackRepository(); 20 + }); 21 + 22 + StarterPackViewBasic buildPack(AtUri uri, String name) { 23 + return StarterPackViewBasic( 24 + uri: uri, 25 + cid: 'cid-${uri.rkey}', 26 + record: <String, dynamic>{ 27 + r'$type': 'app.bsky.graph.starterpack', 28 + 'name': name, 29 + 'list': 'at://did:plc:creator/app.bsky.graph.list/ref', 30 + 'createdAt': '2026-03-22T00:00:00.000Z', 31 + }, 32 + creator: const ProfileViewBasic(did: actor, handle: 'creator.bsky.social'), 33 + listItemCount: 5, 34 + joinedWeekCount: 3, 35 + joinedAllTimeCount: 100, 36 + indexedAt: DateTime.utc(2026, 3, 22), 37 + ); 38 + } 39 + 40 + Widget buildSubject() { 41 + return MultiRepositoryProvider( 42 + providers: [RepositoryProvider<StarterPackRepository>.value(value: mockRepository)], 43 + child: const MaterialApp(home: ActorStarterPacksScreen(actor: actor)), 44 + ); 45 + } 46 + 47 + testWidgets('shows loading state initially', (tester) async { 48 + when( 49 + () => mockRepository.getActorStarterPacks( 50 + actor: any(named: 'actor'), 51 + cursor: any(named: 'cursor'), 52 + limit: any(named: 'limit'), 53 + ), 54 + ).thenAnswer((_) async { 55 + await Future<void>.delayed(const Duration(hours: 1)); 56 + return const ActorStarterPacksResult(starterPacks: []); 57 + }); 58 + 59 + await tester.pumpWidget(buildSubject()); 60 + await tester.pump(); 61 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 62 + await tester.pump(const Duration(hours: 2)); 63 + }); 64 + 65 + testWidgets('shows empty state when no packs', (tester) async { 66 + when( 67 + () => mockRepository.getActorStarterPacks( 68 + actor: any(named: 'actor'), 69 + cursor: any(named: 'cursor'), 70 + limit: any(named: 'limit'), 71 + ), 72 + ).thenAnswer((_) async => const ActorStarterPacksResult(starterPacks: [])); 73 + 74 + await tester.pumpWidget(buildSubject()); 75 + await tester.pumpAndSettle(); 76 + 77 + expect(find.text('No starter packs yet'), findsOneWidget); 78 + }); 79 + 80 + testWidgets('shows pack cards after loading', (tester) async { 81 + final pack1 = buildPack(AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'), 'Pack One'); 82 + final pack2 = buildPack(AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-2'), 'Pack Two'); 83 + 84 + when( 85 + () => mockRepository.getActorStarterPacks( 86 + actor: any(named: 'actor'), 87 + cursor: any(named: 'cursor'), 88 + limit: any(named: 'limit'), 89 + ), 90 + ).thenAnswer((_) async => ActorStarterPacksResult(starterPacks: [pack1, pack2])); 91 + 92 + await tester.pumpWidget(buildSubject()); 93 + await tester.pumpAndSettle(); 94 + 95 + expect(find.text('Pack One'), findsOneWidget); 96 + expect(find.text('Pack Two'), findsOneWidget); 97 + }); 98 + 99 + testWidgets('shows error state with retry button when load fails', (tester) async { 100 + when( 101 + () => mockRepository.getActorStarterPacks( 102 + actor: any(named: 'actor'), 103 + cursor: any(named: 'cursor'), 104 + limit: any(named: 'limit'), 105 + ), 106 + ).thenThrow(Exception('network error')); 107 + 108 + await tester.pumpWidget(buildSubject()); 109 + await tester.pumpAndSettle(); 110 + 111 + expect(find.text('Retry'), findsOneWidget); 112 + }); 113 + 114 + testWidgets('shows Starter Packs in app bar', (tester) async { 115 + when( 116 + () => mockRepository.getActorStarterPacks( 117 + actor: any(named: 'actor'), 118 + cursor: any(named: 'cursor'), 119 + limit: any(named: 'limit'), 120 + ), 121 + ).thenAnswer((_) async => const ActorStarterPacksResult(starterPacks: [])); 122 + 123 + await tester.pumpWidget(buildSubject()); 124 + await tester.pumpAndSettle(); 125 + 126 + expect(find.text('Starter Packs'), findsOneWidget); 127 + }); 128 + }
+182
test/features/starter_packs/presentation/starter_pack_detail_screen_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bluesky/app_bsky_graph_defs.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:flutter_bloc/flutter_bloc.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 9 + import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 10 + import 'package:mocktail/mocktail.dart'; 11 + 12 + class MockStarterPackRepository extends Mock implements StarterPackRepository {} 13 + 14 + void main() { 15 + late MockStarterPackRepository mockRepository; 16 + 17 + final packUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'); 18 + final refListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/ref-1'); 19 + 20 + setUpAll(() { 21 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.starterpack/fallback')); 22 + }); 23 + 24 + setUp(() { 25 + mockRepository = MockStarterPackRepository(); 26 + }); 27 + 28 + StarterPackView buildPack({List<ListItemView>? members, List<GeneratorView>? feeds}) { 29 + return StarterPackView( 30 + uri: packUri, 31 + cid: 'cid-pack', 32 + record: <String, dynamic>{ 33 + r'$type': 'app.bsky.graph.starterpack', 34 + 'name': 'My Starter Pack', 35 + 'description': 'A great pack', 36 + 'list': refListUri.toString(), 37 + 'createdAt': '2026-03-22T00:00:00.000Z', 38 + }, 39 + creator: const ProfileViewBasic( 40 + did: 'did:plc:creator', 41 + handle: 'creator.bsky.social', 42 + displayName: 'The Creator', 43 + ), 44 + list: ListViewBasic( 45 + uri: refListUri, 46 + cid: 'cid-ref', 47 + name: 'Starter Pack Members', 48 + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsReferencelist), 49 + ), 50 + listItemsSample: members, 51 + feeds: feeds, 52 + joinedWeekCount: 12, 53 + joinedAllTimeCount: 350, 54 + indexedAt: DateTime.utc(2026, 3, 22), 55 + ); 56 + } 57 + 58 + Widget buildSubject() { 59 + return MultiRepositoryProvider( 60 + providers: [RepositoryProvider<StarterPackRepository>.value(value: mockRepository)], 61 + child: MaterialApp(home: StarterPackDetailScreen(packUri: packUri)), 62 + ); 63 + } 64 + 65 + testWidgets('shows loading state initially', (tester) async { 66 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async { 67 + await Future<void>.delayed(const Duration(hours: 1)); 68 + return buildPack(); 69 + }); 70 + 71 + await tester.pumpWidget(buildSubject()); 72 + await tester.pump(); 73 + expect(find.byType(CircularProgressIndicator), findsWidgets); 74 + await tester.pump(const Duration(hours: 2)); 75 + }); 76 + 77 + testWidgets('shows pack name and description after loading', (tester) async { 78 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 79 + 80 + await tester.pumpWidget(buildSubject()); 81 + await tester.pumpAndSettle(); 82 + 83 + expect(find.text('My Starter Pack'), findsWidgets); 84 + expect(find.text('A great pack'), findsOneWidget); 85 + }); 86 + 87 + testWidgets('shows creator handle', (tester) async { 88 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 89 + 90 + await tester.pumpWidget(buildSubject()); 91 + await tester.pumpAndSettle(); 92 + 93 + expect(find.text('The Creator'), findsOneWidget); 94 + expect(find.text('@creator.bsky.social'), findsOneWidget); 95 + }); 96 + 97 + testWidgets('shows join stats', (tester) async { 98 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 99 + 100 + await tester.pumpWidget(buildSubject()); 101 + await tester.pumpAndSettle(); 102 + 103 + expect(find.text('12'), findsOneWidget); 104 + expect(find.text('joined this week'), findsOneWidget); 105 + expect(find.text('350'), findsOneWidget); 106 + expect(find.text('total joined'), findsOneWidget); 107 + }); 108 + 109 + testWidgets('shows member avatars when listItemsSample is non-empty', (tester) async { 110 + final member = ListItemView( 111 + uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'), 112 + subject: const ProfileView(did: 'did:plc:member', handle: 'member.bsky.social', displayName: 'A Member'), 113 + ); 114 + 115 + when( 116 + () => mockRepository.getStarterPack(starterPackUri: packUri), 117 + ).thenAnswer((_) async => buildPack(members: [member])); 118 + 119 + await tester.pumpWidget(buildSubject()); 120 + await tester.pumpAndSettle(); 121 + 122 + expect(find.text('A Member'), findsOneWidget); 123 + }); 124 + 125 + testWidgets('shows See all and Follow all buttons when list is present', (tester) async { 126 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 127 + 128 + await tester.pumpWidget(buildSubject()); 129 + await tester.pumpAndSettle(); 130 + 131 + expect(find.text('See all'), findsOneWidget); 132 + expect(find.text('Follow all'), findsOneWidget); 133 + }); 134 + 135 + testWidgets('shows Members section heading', (tester) async { 136 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 137 + 138 + await tester.pumpWidget(buildSubject()); 139 + await tester.pumpAndSettle(); 140 + 141 + expect(find.text('Members'), findsOneWidget); 142 + }); 143 + 144 + testWidgets('shows error state when load fails', (tester) async { 145 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenThrow(Exception('network error')); 146 + 147 + await tester.pumpWidget(buildSubject()); 148 + await tester.pumpAndSettle(); 149 + 150 + expect(find.text('Retry'), findsOneWidget); 151 + }); 152 + 153 + testWidgets('shows Follow all loading indicator while following', (tester) async { 154 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 155 + when(() => mockRepository.followAll(referenceListUri: refListUri)).thenAnswer((_) async { 156 + await Future<void>.delayed(const Duration(hours: 1)); 157 + return 5; 158 + }); 159 + 160 + await tester.pumpWidget(buildSubject()); 161 + await tester.pumpAndSettle(); 162 + 163 + await tester.tap(find.text('Follow all')); 164 + await tester.pump(); 165 + 166 + expect(find.text('Following…'), findsOneWidget); 167 + await tester.pump(const Duration(hours: 2)); 168 + }); 169 + 170 + testWidgets('shows success snackbar after following all members', (tester) async { 171 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 172 + when(() => mockRepository.followAll(referenceListUri: refListUri)).thenAnswer((_) async => 5); 173 + 174 + await tester.pumpWidget(buildSubject()); 175 + await tester.pumpAndSettle(); 176 + 177 + await tester.tap(find.text('Follow all')); 178 + await tester.pumpAndSettle(); 179 + 180 + expect(find.text('Followed 5 members'), findsOneWidget); 181 + }); 182 + }