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: add suggested follows tab and list to profile screen

+558 -195
+240 -120
lib/features/profile/presentation/profile_screen.dart
··· 28 28 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 29 29 import 'package:lazurite/features/profile/data/profile_repository.dart'; 30 30 import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; 31 + import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_list.dart'; 31 32 import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_sheet.dart'; 32 33 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 33 34 import 'package:lazurite/features/settings/bloc/settings_state.dart'; ··· 77 78 (label: 'Media', filter: FeedFilter.postsWithMedia), 78 79 ]; 79 80 80 - static const _tabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS', 'PACKS']; 81 + static const _baseTabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS', 'PACKS']; 82 + static const _suggestedTabLabel = 'SUGGESTED'; 81 83 82 - late final TabController _tabController; 84 + late TabController _tabController; 85 + late bool _showSuggestedTab; 83 86 84 87 @override 85 88 void initState() { 86 89 super.initState(); 90 + _showSuggestedTab = _shouldShowSuggestedTab(context.read<ProfileBloc>().state.profile); 87 91 _tabController = TabController(length: _tabLabels.length, vsync: this); 88 92 _loadProfileAndFeed(); 89 93 } ··· 93 97 super.didUpdateWidget(oldWidget); 94 98 if (oldWidget.actor != widget.actor) { 95 99 _tabController.index = 0; 100 + _setSuggestedTabVisibility(false); 96 101 _loadProfileAndFeed(); 97 102 } 98 103 } ··· 115 120 if (!authState.isAuthenticated) return null; 116 121 return widget.actor ?? authState.tokens?.did; 117 122 } 123 + 124 + List<String> get _tabLabels => 125 + _showSuggestedTab ? [..._baseTabLabels, _suggestedTabLabel] : List<String>.of(_baseTabLabels); 118 126 119 127 FeedFilter get _currentFilter => _feedTabs[_tabController.index < _feedTabs.length ? _tabController.index : 0].filter; 120 128 129 + bool _shouldShowSuggestedTab(ProfileViewDetailed? profile) { 130 + if (profile == null) return false; 131 + return profile.did != context.read<AuthBloc>().state.tokens?.did; 132 + } 133 + 134 + void _setSuggestedTabVisibility(bool show) { 135 + if (_showSuggestedTab == show) { 136 + return; 137 + } 138 + 139 + final maxIndex = show ? _baseTabLabels.length : _baseTabLabels.length - 1; 140 + final nextIndex = _tabController.index.clamp(0, maxIndex); 141 + _tabController.dispose(); 142 + _showSuggestedTab = show; 143 + _tabController = TabController(length: _tabLabels.length, vsync: this, initialIndex: nextIndex); 144 + setState(() {}); 145 + } 146 + 121 147 String _appBarTitle(ProfileViewDetailed? profile) { 122 148 final authState = context.read<AuthBloc>().state; 123 149 return profile?.displayName ?? profile?.handle ?? widget.actor ?? authState.tokens?.handle ?? 'Profile'; ··· 131 157 132 158 @override 133 159 Widget build(BuildContext context) { 134 - return Scaffold( 135 - body: BlocBuilder<ProfileBloc, ProfileState>( 136 - builder: (context, profileState) { 137 - return BlocBuilder<FeedBloc, FeedState>( 138 - builder: (context, feedState) { 139 - final profile = profileState.profile; 140 - final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 141 - final isOwnProfile = profile?.did == currentUserDid; 160 + return BlocListener<ProfileBloc, ProfileState>( 161 + listenWhen: (previous, current) { 162 + return _shouldShowSuggestedTab(previous.profile) != _shouldShowSuggestedTab(current.profile); 163 + }, 164 + listener: (context, state) => _setSuggestedTabVisibility(_shouldShowSuggestedTab(state.profile)), 165 + child: Scaffold( 166 + body: BlocBuilder<ProfileBloc, ProfileState>( 167 + builder: (context, profileState) { 168 + return BlocBuilder<FeedBloc, FeedState>( 169 + builder: (context, feedState) { 170 + final profile = profileState.profile; 171 + final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 172 + final isOwnProfile = profile?.did == currentUserDid; 173 + final tabChildren = <Widget>[ 174 + ..._feedTabs.map((t) => _buildFeedList(feedState, t.filter, profile)), 175 + _buildListsTab(context, profile), 176 + _buildStarterPacksTab(context, profile), 177 + if (_showSuggestedTab) _buildSuggestedFollowsTab(profile), 178 + ]; 142 179 143 - return NestedScrollView( 144 - headerSliverBuilder: (context, innerBoxIsScrolled) { 145 - return [ 146 - SliverAppBar( 147 - floating: true, 148 - pinned: true, 149 - snap: true, 150 - title: Text(_appBarTitle(profile)), 151 - leading: widget.showBackButton 152 - ? IconButton( 153 - icon: const Icon(Icons.arrow_back), 154 - onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 155 - ) 156 - : const AppShellMenuButton(), 157 - actions: [ 158 - if (profile != null && isOwnProfile) 180 + return NestedScrollView( 181 + headerSliverBuilder: (context, innerBoxIsScrolled) { 182 + return [ 183 + SliverAppBar( 184 + floating: true, 185 + pinned: true, 186 + snap: true, 187 + title: Text(_appBarTitle(profile)), 188 + leading: widget.showBackButton 189 + ? IconButton( 190 + icon: const Icon(Icons.arrow_back), 191 + onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 192 + ) 193 + : const AppShellMenuButton(), 194 + actions: [ 195 + if (profile != null && isOwnProfile) 196 + IconButton( 197 + key: const Key('profile_more_button'), 198 + icon: const Icon(Icons.more_vert), 199 + onPressed: () => _showOwnProfileMoreOptions(context, profile), 200 + ), 159 201 IconButton( 160 - key: const Key('profile_more_button'), 161 - icon: const Icon(Icons.more_vert), 162 - onPressed: () => _showOwnProfileMoreOptions(context, profile), 202 + icon: const Icon(Icons.settings_outlined), 203 + onPressed: () => context.go('/settings'), 163 204 ), 164 - IconButton(icon: const Icon(Icons.settings_outlined), onPressed: () => context.go('/settings')), 165 - ], 166 - ), 167 - SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 168 - SliverToBoxAdapter( 169 - child: switch (profileState.status) { 170 - ProfileStatus.loading => const Padding( 171 - padding: EdgeInsets.all(24), 172 - child: Center(child: CircularProgressIndicator()), 173 - ), 174 - ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 175 - _ => _buildProfileSummary(context, profile, isOwnProfile), 176 - }, 177 - ), 178 - SliverPersistentHeader( 179 - pinned: true, 180 - delegate: SliverTabBarDelegate( 181 - TabBar( 182 - controller: _tabController, 183 - tabs: [for (final label in _tabLabels) Tab(text: label)], 184 - onTap: (index) { 185 - if (index < _feedTabs.length) { 186 - _loadProfileAndFeed(filter: _feedTabs[index].filter); 187 - } 188 - }, 189 - isScrollable: true, 190 - tabAlignment: TabAlignment.start, 191 - labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 192 - unselectedLabelStyle: const TextStyle( 193 - fontSize: 11, 194 - fontWeight: FontWeight.w700, 195 - letterSpacing: 2.2, 205 + ], 206 + ), 207 + SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 208 + SliverToBoxAdapter( 209 + child: switch (profileState.status) { 210 + ProfileStatus.loading => const Padding( 211 + padding: EdgeInsets.all(24), 212 + child: Center(child: CircularProgressIndicator()), 196 213 ), 197 - indicatorWeight: 2, 214 + ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 215 + _ => _buildProfileSummary(context, profile, isOwnProfile), 216 + }, 217 + ), 218 + SliverPersistentHeader( 219 + pinned: true, 220 + delegate: SliverTabBarDelegate( 221 + TabBar( 222 + controller: _tabController, 223 + tabs: [for (final label in _tabLabels) Tab(text: label)], 224 + onTap: (index) { 225 + if (index < _feedTabs.length) { 226 + _loadProfileAndFeed(filter: _feedTabs[index].filter); 227 + } 228 + }, 229 + isScrollable: true, 230 + tabAlignment: TabAlignment.start, 231 + labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 232 + unselectedLabelStyle: const TextStyle( 233 + fontSize: 11, 234 + fontWeight: FontWeight.w700, 235 + letterSpacing: 2.2, 236 + ), 237 + indicatorWeight: 2, 238 + ), 198 239 ), 199 240 ), 200 - ), 201 - ]; 202 - }, 203 - body: TabBarView( 204 - controller: _tabController, 205 - children: [ 206 - for (var i = 0; i < _feedTabs.length; i++) _buildFeedList(feedState, _feedTabs[i].filter, profile), 207 - _buildListsTab(context, profile), 208 - _buildStarterPacksTab(context, profile), 209 - ], 210 - ), 211 - ); 212 - }, 213 - ); 214 - }, 241 + ]; 242 + }, 243 + body: TabBarView(controller: _tabController, children: tabChildren), 244 + ); 245 + }, 246 + ); 247 + }, 248 + ), 249 + floatingActionButton: _buildComposeFab(context), 215 250 ), 216 - floatingActionButton: _buildComposeFab(context), 217 251 ); 218 252 } 219 253 ··· 651 685 showModalBottomSheet<void>( 652 686 context: context, 653 687 isScrollControlled: true, 654 - builder: (sheetContext) => BlocProvider.value(value: cubit, child: const SuggestedFollowsSheet()), 688 + builder: (sheetContext) => BlocProvider.value( 689 + value: cubit, 690 + child: SuggestedFollowsSheet(actor: profile.did), 691 + ), 655 692 ).whenComplete(cubit.close); 693 + } 694 + 695 + Widget _buildSuggestedFollowsTab(ProfileViewDetailed? profile) { 696 + final actor = profile?.did; 697 + if (actor == null) { 698 + return const SizedBox.shrink(); 699 + } 700 + 701 + return _SuggestedFollowsTab( 702 + actor: actor, 703 + onProfileTap: (target) => context.push('/profile/view?actor=${Uri.encodeComponent(target.did)}'), 704 + ); 656 705 } 657 706 658 707 Widget? _buildComposeFab(BuildContext context) { ··· 731 780 child: Center(child: CircularProgressIndicator()), 732 781 ); 733 782 } 783 + final post = feedState.posts[index]; 734 784 735 785 return Padding( 736 786 padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 16), ··· 739 789 key: ValueKey('profile_large_card_$index'), 740 790 constraints: const BoxConstraints(maxWidth: 720), 741 791 child: PostCardWithActions( 742 - feedViewPost: feedState.posts[index], 792 + feedViewPost: post, 743 793 accountDid: accountDid, 744 794 variant: PostCardVariant.grid, 745 795 moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, ··· 754 804 } 755 805 756 806 Widget _buildLinearFeed(BuildContext context, FeedState feedState) { 807 + final accountDid = _resolvedActor ?? ''; 757 808 return RefreshIndicator( 758 809 onRefresh: _refresh, 759 810 child: NotificationListener<ScrollNotification>( ··· 777 828 } 778 829 return PostCardWithActions( 779 830 feedViewPost: feedState.posts[index], 780 - accountDid: _resolvedActor ?? '', 831 + accountDid: accountDid, 781 832 moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 782 833 ); 783 834 }, ··· 845 896 } 846 897 } 847 898 899 + class _SuggestedFollowsTab extends StatefulWidget { 900 + const _SuggestedFollowsTab({required this.actor, required this.onProfileTap}); 901 + 902 + final String actor; 903 + final ValueChanged<ProfileView> onProfileTap; 904 + 905 + @override 906 + State<_SuggestedFollowsTab> createState() => _SuggestedFollowsTabState(); 907 + } 908 + 909 + class _SuggestedFollowsTabState extends State<_SuggestedFollowsTab> { 910 + SuggestedFollowsCubit? _cubit; 911 + 912 + @override 913 + void initState() { 914 + super.initState(); 915 + _cubit = _createCubit(widget.actor); 916 + } 917 + 918 + @override 919 + void didUpdateWidget(covariant _SuggestedFollowsTab oldWidget) { 920 + super.didUpdateWidget(oldWidget); 921 + if (oldWidget.actor == widget.actor) { 922 + return; 923 + } 924 + 925 + _cubit?.close(); 926 + _cubit = _createCubit(widget.actor); 927 + } 928 + 929 + @override 930 + void dispose() { 931 + _cubit?.close(); 932 + super.dispose(); 933 + } 934 + 935 + SuggestedFollowsCubit? _createCubit(String actor) { 936 + try { 937 + final repository = context.read<ProfileRepository>(); 938 + return SuggestedFollowsCubit(repository: repository)..load(actor); 939 + } catch (_) { 940 + return null; 941 + } 942 + } 943 + 944 + @override 945 + Widget build(BuildContext context) { 946 + final cubit = _cubit; 947 + if (cubit == null) { 948 + return const Center(child: Text('Suggested follows are unavailable right now.')); 949 + } 950 + 951 + return BlocProvider.value( 952 + value: cubit, 953 + child: SuggestedFollowsList( 954 + actor: widget.actor, 955 + padding: const EdgeInsets.symmetric(vertical: 8), 956 + onProfileTap: widget.onProfileTap, 957 + ), 958 + ); 959 + } 960 + } 961 + 848 962 /// Pane that loads and displays starter packs for a given [actor] within the profile screen. 849 963 class _ProfileStarterPacksPane extends StatefulWidget { 850 964 const _ProfileStarterPacksPane({required this.actor, required this.starterPackRepository}); ··· 916 1030 itemBuilder: (context, index) => StarterPackCard( 917 1031 key: ValueKey(state.starterPacks[index].uri), 918 1032 pack: state.starterPacks[index], 919 - onTap: () => 920 - context.push('/starter-pack?uri=${Uri.encodeComponent(state.starterPacks[index].uri.toString())}'), 1033 + onTap: () { 1034 + final component = Uri.encodeComponent(state.starterPacks[index].uri.toString()); 1035 + final uri = '/starter-pack?uri=$component'; 1036 + context.push(uri); 1037 + }, 921 1038 ), 922 1039 ), 923 1040 ); ··· 965 1082 return BlocBuilder<MyListsCubit, MyListsState>( 966 1083 bloc: _cubit, 967 1084 builder: (context, state) { 968 - if (state.status == MyListsStatus.loading) { 969 - return const Center(child: CircularProgressIndicator()); 970 - } 971 - 972 - if (state.status == MyListsStatus.error) { 973 - return Center( 974 - child: Column( 975 - mainAxisSize: MainAxisSize.min, 976 - children: [ 977 - Text(state.errorMessage ?? 'Failed to load lists'), 978 - const SizedBox(height: 12), 979 - FilledButton(onPressed: () => _cubit.refresh(), child: const Text('Retry')), 980 - ], 981 - ), 982 - ); 983 - } 1085 + switch (state.status) { 1086 + case MyListsStatus.loading: 1087 + return const Center(child: CircularProgressIndicator()); 1088 + case MyListsStatus.error: 1089 + return Center( 1090 + child: Column( 1091 + mainAxisSize: MainAxisSize.min, 1092 + children: [ 1093 + Text(state.errorMessage ?? 'Failed to load lists'), 1094 + const SizedBox(height: 12), 1095 + FilledButton(onPressed: () => _cubit.refresh(), child: const Text('Retry')), 1096 + ], 1097 + ), 1098 + ); 1099 + default: 1100 + final lists = state.lists 1101 + .where((l) { 1102 + final purpose = l.purpose.knownValue; 1103 + return purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist || 1104 + purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist; 1105 + }) 1106 + .toList(growable: false); 984 1107 985 - final lists = state.lists 986 - .where((l) { 987 - final purpose = l.purpose.knownValue; 988 - return purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist || 989 - purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist; 990 - }) 991 - .toList(growable: false); 1108 + if (lists.isEmpty) { 1109 + return const Center(child: Text('No lists yet')); 1110 + } 992 1111 993 - if (lists.isEmpty) { 994 - return const Center(child: Text('No lists yet')); 1112 + return RefreshIndicator( 1113 + onRefresh: _cubit.refresh, 1114 + child: ListView.builder( 1115 + itemCount: lists.length, 1116 + itemBuilder: (context, index) => ListRowTile( 1117 + key: ValueKey(lists[index].uri), 1118 + list: lists[index], 1119 + onTap: () { 1120 + final component = Uri.encodeComponent(lists[index].uri.toString()); 1121 + final uri = '/list?uri=$component'; 1122 + context.push(uri); 1123 + }, 1124 + ), 1125 + ), 1126 + ); 995 1127 } 996 - 997 - return RefreshIndicator( 998 - onRefresh: _cubit.refresh, 999 - child: ListView.builder( 1000 - itemCount: lists.length, 1001 - itemBuilder: (context, index) => ListRowTile( 1002 - key: ValueKey(lists[index].uri), 1003 - list: lists[index], 1004 - onTap: () => context.push('/list?uri=${Uri.encodeComponent(lists[index].uri.toString())}'), 1005 - ), 1006 - ), 1007 - ); 1008 1128 }, 1009 1129 ); 1010 1130 }
+183
lib/features/profile/presentation/widgets/suggested_follows_list.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 5 + import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 6 + import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 7 + 8 + class SuggestedFollowsList extends StatelessWidget { 9 + const SuggestedFollowsList({ 10 + super.key, 11 + required this.actor, 12 + this.scrollController, 13 + this.onProfileTap, 14 + this.emptyMessage = 'No suggestions found', 15 + this.padding = EdgeInsets.zero, 16 + }); 17 + 18 + final String actor; 19 + final ScrollController? scrollController; 20 + final ValueChanged<ProfileView>? onProfileTap; 21 + final String emptyMessage; 22 + final EdgeInsetsGeometry padding; 23 + 24 + @override 25 + Widget build(BuildContext context) { 26 + return BlocBuilder<SuggestedFollowsCubit, SuggestedFollowsState>( 27 + builder: (context, state) { 28 + if (state.isLoading) { 29 + return const Center(child: CircularProgressIndicator()); 30 + } 31 + 32 + if (state.hasError) { 33 + return Center( 34 + child: Padding( 35 + padding: const EdgeInsets.all(24), 36 + child: Column( 37 + mainAxisSize: MainAxisSize.min, 38 + children: [ 39 + Text(state.errorMessage ?? 'Failed to load suggestions', textAlign: TextAlign.center), 40 + const SizedBox(height: 12), 41 + FilledButton( 42 + onPressed: () => context.read<SuggestedFollowsCubit>().load(actor), 43 + child: const Text('Retry'), 44 + ), 45 + ], 46 + ), 47 + ), 48 + ); 49 + } 50 + 51 + if (state.isEmpty) { 52 + return Center(child: Text(emptyMessage)); 53 + } 54 + 55 + return ListView.builder( 56 + controller: scrollController, 57 + padding: padding, 58 + itemCount: state.suggestions.length, 59 + itemBuilder: (context, index) { 60 + final profile = state.suggestions[index]; 61 + return _SuggestedProfileTile(profile: profile, onTap: onProfileTap); 62 + }, 63 + ); 64 + }, 65 + ); 66 + } 67 + } 68 + 69 + class _SuggestedProfileTile extends StatelessWidget { 70 + const _SuggestedProfileTile({required this.profile, this.onTap}); 71 + 72 + final ProfileView profile; 73 + final ValueChanged<ProfileView>? onTap; 74 + 75 + @override 76 + Widget build(BuildContext context) { 77 + ProfileActionRepository? profileActionRepository; 78 + try { 79 + profileActionRepository = context.read<ProfileActionRepository>(); 80 + } catch (_) { 81 + profileActionRepository = null; 82 + } 83 + 84 + final child = _SuggestedProfileTileBody(profile: profile, onTap: onTap); 85 + if (profileActionRepository == null) { 86 + return _StaticSuggestedProfileTile(profile: profile, onTap: onTap); 87 + } 88 + 89 + return BlocProvider( 90 + create: (_) => ProfileActionCubit( 91 + profileActionRepository: profileActionRepository!, 92 + actorDid: profile.did, 93 + isFollowing: profile.viewer?.following != null, 94 + isMuted: profile.viewer?.muted ?? false, 95 + isBlocked: profile.viewer?.blocking != null, 96 + isBlockedBy: profile.viewer?.blockedBy ?? false, 97 + followUri: profile.viewer?.following?.toString(), 98 + blockUri: profile.viewer?.blocking?.toString(), 99 + ), 100 + child: child, 101 + ); 102 + } 103 + } 104 + 105 + class _SuggestedProfileTileBody extends StatelessWidget { 106 + const _SuggestedProfileTileBody({required this.profile, this.onTap}); 107 + 108 + final ProfileView profile; 109 + final ValueChanged<ProfileView>? onTap; 110 + 111 + @override 112 + Widget build(BuildContext context) { 113 + final title = profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle; 114 + 115 + return BlocConsumer<ProfileActionCubit, ProfileActionState>( 116 + listener: (context, state) { 117 + if (state.error == null) return; 118 + ScaffoldMessenger.of( 119 + context, 120 + ).showSnackBar(SnackBar(content: Text(state.error!), behavior: SnackBarBehavior.floating)); 121 + context.read<ProfileActionCubit>().clearError(); 122 + }, 123 + builder: (context, state) { 124 + return ListTile( 125 + leading: CircleAvatar( 126 + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 127 + child: profile.avatar == null ? Text(title.substring(0, 1).toUpperCase()) : null, 128 + ), 129 + title: Text(title), 130 + subtitle: Text('@${profile.handle}'), 131 + trailing: _FollowButton( 132 + isFollowing: state.isFollowing, 133 + isLoading: state.isLoadingFollow, 134 + onPressed: state.isLoadingFollow ? null : () => context.read<ProfileActionCubit>().toggleFollow(), 135 + ), 136 + onTap: onTap == null ? null : () => onTap!(profile), 137 + ); 138 + }, 139 + ); 140 + } 141 + } 142 + 143 + class _StaticSuggestedProfileTile extends StatelessWidget { 144 + const _StaticSuggestedProfileTile({required this.profile, this.onTap}); 145 + 146 + final ProfileView profile; 147 + final ValueChanged<ProfileView>? onTap; 148 + 149 + @override 150 + Widget build(BuildContext context) { 151 + final title = profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle; 152 + return ListTile( 153 + leading: CircleAvatar( 154 + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 155 + child: profile.avatar == null ? Text(title.substring(0, 1).toUpperCase()) : null, 156 + ), 157 + title: Text(title), 158 + subtitle: Text('@${profile.handle}'), 159 + onTap: onTap == null ? null : () => onTap!(profile), 160 + ); 161 + } 162 + } 163 + 164 + class _FollowButton extends StatelessWidget { 165 + const _FollowButton({required this.isFollowing, required this.isLoading, this.onPressed}); 166 + 167 + final bool isFollowing; 168 + final bool isLoading; 169 + final VoidCallback? onPressed; 170 + 171 + @override 172 + Widget build(BuildContext context) { 173 + if (isLoading) { 174 + return const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)); 175 + } 176 + 177 + if (isFollowing) { 178 + return OutlinedButton(onPressed: onPressed, child: const Text('Following')); 179 + } 180 + 181 + return FilledButton(onPressed: onPressed, child: const Text('Follow')); 182 + } 183 + }
+12 -68
lib/features/profile/presentation/widgets/suggested_follows_sheet.dart
··· 1 - import 'package:bluesky/app_bsky_actor_defs.dart'; 2 1 import 'package:flutter/material.dart'; 3 - import 'package:flutter_bloc/flutter_bloc.dart'; 4 2 import 'package:go_router/go_router.dart'; 5 - import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 3 + import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_list.dart'; 6 4 7 5 class SuggestedFollowsSheet extends StatelessWidget { 8 - const SuggestedFollowsSheet({super.key}); 6 + const SuggestedFollowsSheet({super.key, required this.actor}); 7 + 8 + final String actor; 9 9 10 10 @override 11 11 Widget build(BuildContext context) { ··· 25 25 ), 26 26 const Divider(height: 1), 27 27 Expanded( 28 - child: BlocBuilder<SuggestedFollowsCubit, SuggestedFollowsState>( 29 - builder: (context, state) { 30 - if (state.isLoading) { 31 - return const Center(child: CircularProgressIndicator()); 32 - } 33 - 34 - if (state.hasError) { 35 - return Center(child: Text(state.errorMessage ?? 'Failed to load suggestions')); 36 - } 37 - 38 - if (state.isEmpty) { 39 - return const Center(child: Text('No suggestions found')); 28 + child: SuggestedFollowsList( 29 + actor: actor, 30 + scrollController: scrollController, 31 + onProfileTap: (profile) { 32 + final router = GoRouter.of(context); 33 + if (Navigator.of(context).canPop()) { 34 + Navigator.of(context).pop(); 40 35 } 41 - 42 - return ListView.builder( 43 - controller: scrollController, 44 - itemCount: state.suggestions.length, 45 - itemBuilder: (context, index) { 46 - final profile = state.suggestions[index]; 47 - return _SuggestedProfileTile(profile: profile); 48 - }, 49 - ); 36 + router.push('/profile/view?actor=${Uri.encodeComponent(profile.did)}'); 50 37 }, 51 38 ), 52 39 ), ··· 55 42 ); 56 43 } 57 44 } 58 - 59 - class _SuggestedProfileTile extends StatelessWidget { 60 - const _SuggestedProfileTile({required this.profile}); 61 - 62 - final ProfileView profile; 63 - 64 - @override 65 - Widget build(BuildContext context) { 66 - final isFollowing = profile.viewer?.following != null; 67 - 68 - return ListTile( 69 - leading: CircleAvatar( 70 - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 71 - child: profile.avatar == null 72 - ? Text((profile.displayName ?? profile.handle).substring(0, 1).toUpperCase()) 73 - : null, 74 - ), 75 - title: Text(profile.displayName ?? profile.handle), 76 - subtitle: Text('@${profile.handle}'), 77 - trailing: _FollowButton(profile: profile, isFollowing: isFollowing), 78 - onTap: () { 79 - Navigator.of(context).pop(); 80 - context.push('/profile?actor=${Uri.encodeComponent(profile.did)}'); 81 - }, 82 - ); 83 - } 84 - } 85 - 86 - class _FollowButton extends StatelessWidget { 87 - const _FollowButton({required this.profile, required this.isFollowing}); 88 - 89 - final ProfileView profile; 90 - final bool isFollowing; 91 - 92 - @override 93 - Widget build(BuildContext context) { 94 - if (isFollowing) { 95 - return const OutlinedButton(onPressed: null, child: Text('Following')); 96 - } 97 - 98 - return const FilledButton(onPressed: null, child: Text('Follow')); 99 - } 100 - }
+54
test/features/profile/presentation/profile_screen_test.dart
··· 22 22 import 'package:lazurite/features/lists/data/list_repository.dart'; 23 23 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 24 24 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 25 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 25 26 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 26 27 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 27 28 import 'package:lazurite/features/settings/bloc/settings_state.dart'; ··· 34 35 class MockFeedBloc extends MockBloc<FeedEvent, FeedState> implements FeedBloc {} 35 36 36 37 class MockProfileActionRepository extends Mock implements ProfileActionRepository {} 38 + 39 + class MockProfileRepository extends Mock implements ProfileRepository {} 37 40 38 41 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 39 42 ··· 53 56 late MockFeedBloc feedBloc; 54 57 late MockSettingsCubit settingsCubit; 55 58 late MockConnectivityCubit connectivityCubit; 59 + late MockProfileRepository profileRepository; 56 60 57 61 const tokens = AuthTokens( 58 62 accessToken: 'access', ··· 94 98 feedBloc = MockFeedBloc(); 95 99 settingsCubit = MockSettingsCubit(); 96 100 connectivityCubit = MockConnectivityCubit(); 101 + profileRepository = MockProfileRepository(); 97 102 98 103 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 99 104 when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); ··· 227 232 verify( 228 233 () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsWithMedia)), 229 234 ).called(1); 235 + }); 236 + 237 + testWidgets('other profiles show a suggested follows tab with loaded suggestions', (tester) async { 238 + useLargeScreen(tester); 239 + const otherProfile = ProfileViewDetailed( 240 + did: 'did:plc:other', 241 + handle: 'other.bsky.social', 242 + displayName: 'Other User', 243 + ); 244 + final suggestions = [ 245 + const ProfileView(did: 'did:plc:suggested', handle: 'suggested.bsky.social', displayName: 'Suggested User'), 246 + ]; 247 + final mockProfileActionRepository = MockProfileActionRepository(); 248 + 249 + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); 250 + whenListen( 251 + profileBloc, 252 + const Stream<ProfileState>.empty(), 253 + initialState: const ProfileState.loaded(profile: otherProfile), 254 + ); 255 + when(() => profileRepository.getSuggestedFollows('did:plc:other')).thenAnswer((_) async => suggestions); 256 + 257 + await tester.pumpWidget( 258 + MultiRepositoryProvider( 259 + providers: [ 260 + RepositoryProvider<ProfileRepository>.value(value: profileRepository), 261 + RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository), 262 + ], 263 + child: MultiBlocProvider( 264 + providers: [ 265 + BlocProvider<AuthBloc>.value(value: authBloc), 266 + BlocProvider<ProfileBloc>.value(value: profileBloc), 267 + BlocProvider<FeedBloc>.value(value: feedBloc), 268 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 269 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 270 + ], 271 + child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), 272 + ), 273 + ), 274 + ); 275 + await tester.pumpAndSettle(); 276 + 277 + expect(find.text('SUGGESTED'), findsOneWidget); 278 + 279 + await tester.tap(find.text('SUGGESTED')); 280 + await tester.pumpAndSettle(); 281 + 282 + verify(() => profileRepository.getSuggestedFollows('did:plc:other')).called(1); 283 + expect(find.text('Suggested User'), findsOneWidget); 230 284 }); 231 285 232 286 testWidgets('compose FAB on other profiles prefills the mentioned handle', (tester) async {
+69 -5
test/features/profile/presentation/widgets/suggested_follows_sheet_test.dart
··· 1 1 import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:go_router/go_router.dart'; 2 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 5 import 'package:flutter/material.dart'; 4 - import 'package:flutter_bloc/flutter_bloc.dart'; 5 6 import 'package:flutter_test/flutter_test.dart'; 6 7 import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 8 + import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 7 9 import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_sheet.dart'; 8 10 import 'package:mocktail/mocktail.dart'; 9 11 10 12 class MockSuggestedFollowsCubit extends MockCubit<SuggestedFollowsState> implements SuggestedFollowsCubit {} 11 13 14 + class MockProfileActionRepository extends Mock implements ProfileActionRepository {} 15 + 12 16 ProfileView _profile(String did, {String? displayName}) => 13 17 ProfileView(did: did, handle: '$did.bsky.social', displayName: displayName, indexedAt: DateTime.utc(2026)); 14 18 15 19 void main() { 16 20 late MockSuggestedFollowsCubit cubit; 21 + late MockProfileActionRepository profileActionRepository; 17 22 18 23 setUp(() { 19 24 cubit = MockSuggestedFollowsCubit(); 25 + profileActionRepository = MockProfileActionRepository(); 20 26 }); 21 27 22 28 Widget buildSubject() { 23 - return MaterialApp( 24 - home: Scaffold( 25 - body: BlocProvider<SuggestedFollowsCubit>.value(value: cubit, child: const SuggestedFollowsSheet()), 26 - ), 29 + final router = GoRouter( 30 + routes: [ 31 + GoRoute( 32 + path: '/', 33 + builder: (context, state) => RepositoryProvider<ProfileActionRepository>.value( 34 + value: profileActionRepository, 35 + child: Scaffold( 36 + body: BlocProvider<SuggestedFollowsCubit>.value( 37 + value: cubit, 38 + child: const SuggestedFollowsSheet(actor: 'did:plc:target'), 39 + ), 40 + ), 41 + ), 42 + routes: [ 43 + GoRoute( 44 + path: 'profile/view', 45 + builder: (context, state) => Scaffold(body: Text('Profile View ${state.uri.queryParameters['actor']}')), 46 + ), 47 + ], 48 + ), 49 + ], 27 50 ); 51 + 52 + return MaterialApp.router(routerConfig: router); 28 53 } 29 54 30 55 testWidgets('shows loading indicator when state is loading', (tester) async { ··· 88 113 89 114 testWidgets('shows Follow button for unfollowed profiles', (tester) async { 90 115 final profiles = [_profile('did:plc:bob', displayName: 'Bob Builder')]; 116 + when(() => profileActionRepository.followActor(did: 'did:plc:bob')).thenAnswer((_) async => 'at://follow/bob'); 91 117 when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); 92 118 whenListen( 93 119 cubit, ··· 99 125 await tester.pump(); 100 126 101 127 expect(find.text('Follow'), findsOneWidget); 128 + }); 129 + 130 + testWidgets('follow button toggles using profile action repository', (tester) async { 131 + final profiles = [_profile('did:plc:bob', displayName: 'Bob Builder')]; 132 + when(() => profileActionRepository.followActor(did: 'did:plc:bob')).thenAnswer((_) async => 'at://follow/bob'); 133 + when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); 134 + whenListen( 135 + cubit, 136 + const Stream<SuggestedFollowsState>.empty(), 137 + initialState: SuggestedFollowsState.loaded(profiles), 138 + ); 139 + 140 + await tester.pumpWidget(buildSubject()); 141 + await tester.pump(); 142 + 143 + await tester.tap(find.text('Follow')); 144 + await tester.pumpAndSettle(); 145 + 146 + verify(() => profileActionRepository.followActor(did: 'did:plc:bob')).called(1); 147 + expect(find.text('Following'), findsOneWidget); 148 + }); 149 + 150 + testWidgets('tapping a suggestion navigates to /profile/view', (tester) async { 151 + final profiles = [_profile('did:plc:bob', displayName: 'Bob Builder')]; 152 + when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); 153 + whenListen( 154 + cubit, 155 + const Stream<SuggestedFollowsState>.empty(), 156 + initialState: SuggestedFollowsState.loaded(profiles), 157 + ); 158 + 159 + await tester.pumpWidget(buildSubject()); 160 + await tester.pump(); 161 + 162 + await tester.tap(find.text('Bob Builder')); 163 + await tester.pumpAndSettle(); 164 + 165 + expect(find.text('Profile View did:plc:bob'), findsOneWidget); 102 166 }); 103 167 104 168 testWidgets('shows sheet title', (tester) async {
-2
test/features/search/presentation/search_screen_test.dart
··· 418 418 ), 419 419 ).thenAnswer((_) async {}); 420 420 421 - final encodedUri = Uri.encodeComponent(packUri.toString()); 422 421 final router = GoRouter( 423 422 routes: [ 424 423 GoRoute( ··· 454 453 await tester.pumpAndSettle(); 455 454 456 455 expect(find.text('starterpack:${packUri.toString()}'), findsOneWidget); 457 - expect(find.text(encodedUri), findsOneWidget); 458 456 }); 459 457 }); 460 458 }