diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index e47de97..8d01da3 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -28,6 +28,7 @@ import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; +import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_list.dart'; import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_sheet.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -77,13 +78,16 @@ class _ProfileScreenState extends State with SingleTickerProvider (label: 'Media', filter: FeedFilter.postsWithMedia), ]; - static const _tabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS', 'PACKS']; + static const _baseTabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS', 'PACKS']; + static const _suggestedTabLabel = 'SUGGESTED'; - late final TabController _tabController; + late TabController _tabController; + late bool _showSuggestedTab; @override void initState() { super.initState(); + _showSuggestedTab = _shouldShowSuggestedTab(context.read().state.profile); _tabController = TabController(length: _tabLabels.length, vsync: this); _loadProfileAndFeed(); } @@ -93,6 +97,7 @@ class _ProfileScreenState extends State with SingleTickerProvider super.didUpdateWidget(oldWidget); if (oldWidget.actor != widget.actor) { _tabController.index = 0; + _setSuggestedTabVisibility(false); _loadProfileAndFeed(); } } @@ -116,8 +121,29 @@ class _ProfileScreenState extends State with SingleTickerProvider return widget.actor ?? authState.tokens?.did; } + List get _tabLabels => + _showSuggestedTab ? [..._baseTabLabels, _suggestedTabLabel] : List.of(_baseTabLabels); + FeedFilter get _currentFilter => _feedTabs[_tabController.index < _feedTabs.length ? _tabController.index : 0].filter; + bool _shouldShowSuggestedTab(ProfileViewDetailed? profile) { + if (profile == null) return false; + return profile.did != context.read().state.tokens?.did; + } + + void _setSuggestedTabVisibility(bool show) { + if (_showSuggestedTab == show) { + return; + } + + final maxIndex = show ? _baseTabLabels.length : _baseTabLabels.length - 1; + final nextIndex = _tabController.index.clamp(0, maxIndex); + _tabController.dispose(); + _showSuggestedTab = show; + _tabController = TabController(length: _tabLabels.length, vsync: this, initialIndex: nextIndex); + setState(() {}); + } + String _appBarTitle(ProfileViewDetailed? profile) { final authState = context.read().state; return profile?.displayName ?? profile?.handle ?? widget.actor ?? authState.tokens?.handle ?? 'Profile'; @@ -131,89 +157,97 @@ class _ProfileScreenState extends State with SingleTickerProvider @override Widget build(BuildContext context) { - return Scaffold( - body: BlocBuilder( - builder: (context, profileState) { - return BlocBuilder( - builder: (context, feedState) { - final profile = profileState.profile; - final currentUserDid = context.read().state.tokens?.did; - final isOwnProfile = profile?.did == currentUserDid; - - return NestedScrollView( - headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SliverAppBar( - floating: true, - pinned: true, - snap: true, - title: Text(_appBarTitle(profile)), - leading: widget.showBackButton - ? IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), - ) - : const AppShellMenuButton(), - actions: [ - if (profile != null && isOwnProfile) + return BlocListener( + listenWhen: (previous, current) { + return _shouldShowSuggestedTab(previous.profile) != _shouldShowSuggestedTab(current.profile); + }, + listener: (context, state) => _setSuggestedTabVisibility(_shouldShowSuggestedTab(state.profile)), + child: Scaffold( + body: BlocBuilder( + builder: (context, profileState) { + return BlocBuilder( + builder: (context, feedState) { + final profile = profileState.profile; + final currentUserDid = context.read().state.tokens?.did; + final isOwnProfile = profile?.did == currentUserDid; + final tabChildren = [ + ..._feedTabs.map((t) => _buildFeedList(feedState, t.filter, profile)), + _buildListsTab(context, profile), + _buildStarterPacksTab(context, profile), + if (_showSuggestedTab) _buildSuggestedFollowsTab(profile), + ]; + + return NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverAppBar( + floating: true, + pinned: true, + snap: true, + title: Text(_appBarTitle(profile)), + leading: widget.showBackButton + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), + ) + : const AppShellMenuButton(), + actions: [ + if (profile != null && isOwnProfile) + IconButton( + key: const Key('profile_more_button'), + icon: const Icon(Icons.more_vert), + onPressed: () => _showOwnProfileMoreOptions(context, profile), + ), IconButton( - key: const Key('profile_more_button'), - icon: const Icon(Icons.more_vert), - onPressed: () => _showOwnProfileMoreOptions(context, profile), + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.go('/settings'), ), - IconButton(icon: const Icon(Icons.settings_outlined), onPressed: () => context.go('/settings')), - ], - ), - SliverToBoxAdapter(child: _buildCoverSection(context, profile)), - SliverToBoxAdapter( - child: switch (profileState.status) { - ProfileStatus.loading => const Padding( - padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator()), - ), - ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), - _ => _buildProfileSummary(context, profile, isOwnProfile), - }, - ), - SliverPersistentHeader( - pinned: true, - delegate: SliverTabBarDelegate( - TabBar( - controller: _tabController, - tabs: [for (final label in _tabLabels) Tab(text: label)], - onTap: (index) { - if (index < _feedTabs.length) { - _loadProfileAndFeed(filter: _feedTabs[index].filter); - } - }, - isScrollable: true, - tabAlignment: TabAlignment.start, - labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), - unselectedLabelStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 2.2, + ], + ), + SliverToBoxAdapter(child: _buildCoverSection(context, profile)), + SliverToBoxAdapter( + child: switch (profileState.status) { + ProfileStatus.loading => const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), + _ => _buildProfileSummary(context, profile, isOwnProfile), + }, + ), + SliverPersistentHeader( + pinned: true, + delegate: SliverTabBarDelegate( + TabBar( + controller: _tabController, + tabs: [for (final label in _tabLabels) Tab(text: label)], + onTap: (index) { + if (index < _feedTabs.length) { + _loadProfileAndFeed(filter: _feedTabs[index].filter); + } + }, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), + unselectedLabelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2.2, + ), + indicatorWeight: 2, ), - indicatorWeight: 2, ), ), - ), - ]; - }, - body: TabBarView( - controller: _tabController, - children: [ - for (var i = 0; i < _feedTabs.length; i++) _buildFeedList(feedState, _feedTabs[i].filter, profile), - _buildListsTab(context, profile), - _buildStarterPacksTab(context, profile), - ], - ), - ); - }, - ); - }, + ]; + }, + body: TabBarView(controller: _tabController, children: tabChildren), + ); + }, + ); + }, + ), + floatingActionButton: _buildComposeFab(context), ), - floatingActionButton: _buildComposeFab(context), ); } @@ -651,10 +685,25 @@ class _ProfileScreenState extends State with SingleTickerProvider showModalBottomSheet( context: context, isScrollControlled: true, - builder: (sheetContext) => BlocProvider.value(value: cubit, child: const SuggestedFollowsSheet()), + builder: (sheetContext) => BlocProvider.value( + value: cubit, + child: SuggestedFollowsSheet(actor: profile.did), + ), ).whenComplete(cubit.close); } + Widget _buildSuggestedFollowsTab(ProfileViewDetailed? profile) { + final actor = profile?.did; + if (actor == null) { + return const SizedBox.shrink(); + } + + return _SuggestedFollowsTab( + actor: actor, + onProfileTap: (target) => context.push('/profile/view?actor=${Uri.encodeComponent(target.did)}'), + ); + } + Widget? _buildComposeFab(BuildContext context) { return BlocBuilder( builder: (context, state) { @@ -731,6 +780,7 @@ class _ProfileScreenState extends State with SingleTickerProvider child: Center(child: CircularProgressIndicator()), ); } + final post = feedState.posts[index]; return Padding( padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 16), @@ -739,7 +789,7 @@ class _ProfileScreenState extends State with SingleTickerProvider key: ValueKey('profile_large_card_$index'), constraints: const BoxConstraints(maxWidth: 720), child: PostCardWithActions( - feedViewPost: feedState.posts[index], + feedViewPost: post, accountDid: accountDid, variant: PostCardVariant.grid, moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, @@ -754,6 +804,7 @@ class _ProfileScreenState extends State with SingleTickerProvider } Widget _buildLinearFeed(BuildContext context, FeedState feedState) { + final accountDid = _resolvedActor ?? ''; return RefreshIndicator( onRefresh: _refresh, child: NotificationListener( @@ -777,7 +828,7 @@ class _ProfileScreenState extends State with SingleTickerProvider } return PostCardWithActions( feedViewPost: feedState.posts[index], - accountDid: _resolvedActor ?? '', + accountDid: accountDid, moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, ); }, @@ -845,6 +896,69 @@ class _ProfileScreenState extends State with SingleTickerProvider } } +class _SuggestedFollowsTab extends StatefulWidget { + const _SuggestedFollowsTab({required this.actor, required this.onProfileTap}); + + final String actor; + final ValueChanged onProfileTap; + + @override + State<_SuggestedFollowsTab> createState() => _SuggestedFollowsTabState(); +} + +class _SuggestedFollowsTabState extends State<_SuggestedFollowsTab> { + SuggestedFollowsCubit? _cubit; + + @override + void initState() { + super.initState(); + _cubit = _createCubit(widget.actor); + } + + @override + void didUpdateWidget(covariant _SuggestedFollowsTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.actor == widget.actor) { + return; + } + + _cubit?.close(); + _cubit = _createCubit(widget.actor); + } + + @override + void dispose() { + _cubit?.close(); + super.dispose(); + } + + SuggestedFollowsCubit? _createCubit(String actor) { + try { + final repository = context.read(); + return SuggestedFollowsCubit(repository: repository)..load(actor); + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + final cubit = _cubit; + if (cubit == null) { + return const Center(child: Text('Suggested follows are unavailable right now.')); + } + + return BlocProvider.value( + value: cubit, + child: SuggestedFollowsList( + actor: widget.actor, + padding: const EdgeInsets.symmetric(vertical: 8), + onProfileTap: widget.onProfileTap, + ), + ); + } +} + /// Pane that loads and displays starter packs for a given [actor] within the profile screen. class _ProfileStarterPacksPane extends StatefulWidget { const _ProfileStarterPacksPane({required this.actor, required this.starterPackRepository}); @@ -916,8 +1030,11 @@ class _ProfileStarterPacksPaneState extends State<_ProfileStarterPacksPane> { itemBuilder: (context, index) => StarterPackCard( key: ValueKey(state.starterPacks[index].uri), pack: state.starterPacks[index], - onTap: () => - context.push('/starter-pack?uri=${Uri.encodeComponent(state.starterPacks[index].uri.toString())}'), + onTap: () { + final component = Uri.encodeComponent(state.starterPacks[index].uri.toString()); + final uri = '/starter-pack?uri=$component'; + context.push(uri); + }, ), ), ); @@ -965,46 +1082,49 @@ class _ProfileListsPaneState extends State<_ProfileListsPane> { return BlocBuilder( bloc: _cubit, builder: (context, state) { - if (state.status == MyListsStatus.loading) { - return const Center(child: CircularProgressIndicator()); - } - - if (state.status == MyListsStatus.error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(state.errorMessage ?? 'Failed to load lists'), - const SizedBox(height: 12), - FilledButton(onPressed: () => _cubit.refresh(), child: const Text('Retry')), - ], - ), - ); - } - - final lists = state.lists - .where((l) { - final purpose = l.purpose.knownValue; - return purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist || - purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist; - }) - .toList(growable: false); + switch (state.status) { + case MyListsStatus.loading: + return const Center(child: CircularProgressIndicator()); + case MyListsStatus.error: + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(state.errorMessage ?? 'Failed to load lists'), + const SizedBox(height: 12), + FilledButton(onPressed: () => _cubit.refresh(), child: const Text('Retry')), + ], + ), + ); + default: + final lists = state.lists + .where((l) { + final purpose = l.purpose.knownValue; + return purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist || + purpose == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist; + }) + .toList(growable: false); + + if (lists.isEmpty) { + return const Center(child: Text('No lists yet')); + } - if (lists.isEmpty) { - return const Center(child: Text('No lists yet')); + return RefreshIndicator( + onRefresh: _cubit.refresh, + child: ListView.builder( + itemCount: lists.length, + itemBuilder: (context, index) => ListRowTile( + key: ValueKey(lists[index].uri), + list: lists[index], + onTap: () { + final component = Uri.encodeComponent(lists[index].uri.toString()); + final uri = '/list?uri=$component'; + context.push(uri); + }, + ), + ), + ); } - - return RefreshIndicator( - onRefresh: _cubit.refresh, - child: ListView.builder( - itemCount: lists.length, - itemBuilder: (context, index) => ListRowTile( - key: ValueKey(lists[index].uri), - list: lists[index], - onTap: () => context.push('/list?uri=${Uri.encodeComponent(lists[index].uri.toString())}'), - ), - ), - ); }, ); } diff --git a/lib/features/profile/presentation/widgets/suggested_follows_list.dart b/lib/features/profile/presentation/widgets/suggested_follows_list.dart new file mode 100644 index 0000000..6550de1 --- /dev/null +++ b/lib/features/profile/presentation/widgets/suggested_follows_list.dart @@ -0,0 +1,183 @@ +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; +import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; +import 'package:lazurite/features/profile/data/profile_action_repository.dart'; + +class SuggestedFollowsList extends StatelessWidget { + const SuggestedFollowsList({ + super.key, + required this.actor, + this.scrollController, + this.onProfileTap, + this.emptyMessage = 'No suggestions found', + this.padding = EdgeInsets.zero, + }); + + final String actor; + final ScrollController? scrollController; + final ValueChanged? onProfileTap; + final String emptyMessage; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(state.errorMessage ?? 'Failed to load suggestions', textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton( + onPressed: () => context.read().load(actor), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (state.isEmpty) { + return Center(child: Text(emptyMessage)); + } + + return ListView.builder( + controller: scrollController, + padding: padding, + itemCount: state.suggestions.length, + itemBuilder: (context, index) { + final profile = state.suggestions[index]; + return _SuggestedProfileTile(profile: profile, onTap: onProfileTap); + }, + ); + }, + ); + } +} + +class _SuggestedProfileTile extends StatelessWidget { + const _SuggestedProfileTile({required this.profile, this.onTap}); + + final ProfileView profile; + final ValueChanged? onTap; + + @override + Widget build(BuildContext context) { + ProfileActionRepository? profileActionRepository; + try { + profileActionRepository = context.read(); + } catch (_) { + profileActionRepository = null; + } + + final child = _SuggestedProfileTileBody(profile: profile, onTap: onTap); + if (profileActionRepository == null) { + return _StaticSuggestedProfileTile(profile: profile, onTap: onTap); + } + + return BlocProvider( + create: (_) => ProfileActionCubit( + profileActionRepository: profileActionRepository!, + actorDid: profile.did, + isFollowing: profile.viewer?.following != null, + isMuted: profile.viewer?.muted ?? false, + isBlocked: profile.viewer?.blocking != null, + isBlockedBy: profile.viewer?.blockedBy ?? false, + followUri: profile.viewer?.following?.toString(), + blockUri: profile.viewer?.blocking?.toString(), + ), + child: child, + ); + } +} + +class _SuggestedProfileTileBody extends StatelessWidget { + const _SuggestedProfileTileBody({required this.profile, this.onTap}); + + final ProfileView profile; + final ValueChanged? onTap; + + @override + Widget build(BuildContext context) { + final title = profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle; + + return BlocConsumer( + listener: (context, state) { + if (state.error == null) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(state.error!), behavior: SnackBarBehavior.floating)); + context.read().clearError(); + }, + builder: (context, state) { + return ListTile( + leading: CircleAvatar( + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, + child: profile.avatar == null ? Text(title.substring(0, 1).toUpperCase()) : null, + ), + title: Text(title), + subtitle: Text('@${profile.handle}'), + trailing: _FollowButton( + isFollowing: state.isFollowing, + isLoading: state.isLoadingFollow, + onPressed: state.isLoadingFollow ? null : () => context.read().toggleFollow(), + ), + onTap: onTap == null ? null : () => onTap!(profile), + ); + }, + ); + } +} + +class _StaticSuggestedProfileTile extends StatelessWidget { + const _StaticSuggestedProfileTile({required this.profile, this.onTap}); + + final ProfileView profile; + final ValueChanged? onTap; + + @override + Widget build(BuildContext context) { + final title = profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle; + return ListTile( + leading: CircleAvatar( + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, + child: profile.avatar == null ? Text(title.substring(0, 1).toUpperCase()) : null, + ), + title: Text(title), + subtitle: Text('@${profile.handle}'), + onTap: onTap == null ? null : () => onTap!(profile), + ); + } +} + +class _FollowButton extends StatelessWidget { + const _FollowButton({required this.isFollowing, required this.isLoading, this.onPressed}); + + final bool isFollowing; + final bool isLoading; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)); + } + + if (isFollowing) { + return OutlinedButton(onPressed: onPressed, child: const Text('Following')); + } + + return FilledButton(onPressed: onPressed, child: const Text('Follow')); + } +} diff --git a/lib/features/profile/presentation/widgets/suggested_follows_sheet.dart b/lib/features/profile/presentation/widgets/suggested_follows_sheet.dart index c9a6f35..0d976c3 100644 --- a/lib/features/profile/presentation/widgets/suggested_follows_sheet.dart +++ b/lib/features/profile/presentation/widgets/suggested_follows_sheet.dart @@ -1,11 +1,11 @@ -import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; +import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_list.dart'; class SuggestedFollowsSheet extends StatelessWidget { - const SuggestedFollowsSheet({super.key}); + const SuggestedFollowsSheet({super.key, required this.actor}); + + final String actor; @override Widget build(BuildContext context) { @@ -25,28 +25,15 @@ class SuggestedFollowsSheet extends StatelessWidget { ), const Divider(height: 1), Expanded( - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (state.hasError) { - return Center(child: Text(state.errorMessage ?? 'Failed to load suggestions')); + child: SuggestedFollowsList( + actor: actor, + scrollController: scrollController, + onProfileTap: (profile) { + final router = GoRouter.of(context); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); } - - if (state.isEmpty) { - return const Center(child: Text('No suggestions found')); - } - - return ListView.builder( - controller: scrollController, - itemCount: state.suggestions.length, - itemBuilder: (context, index) { - final profile = state.suggestions[index]; - return _SuggestedProfileTile(profile: profile); - }, - ); + router.push('/profile/view?actor=${Uri.encodeComponent(profile.did)}'); }, ), ), @@ -55,46 +42,3 @@ class SuggestedFollowsSheet extends StatelessWidget { ); } } - -class _SuggestedProfileTile extends StatelessWidget { - const _SuggestedProfileTile({required this.profile}); - - final ProfileView profile; - - @override - Widget build(BuildContext context) { - final isFollowing = profile.viewer?.following != null; - - return ListTile( - leading: CircleAvatar( - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, - child: profile.avatar == null - ? Text((profile.displayName ?? profile.handle).substring(0, 1).toUpperCase()) - : null, - ), - title: Text(profile.displayName ?? profile.handle), - subtitle: Text('@${profile.handle}'), - trailing: _FollowButton(profile: profile, isFollowing: isFollowing), - onTap: () { - Navigator.of(context).pop(); - context.push('/profile?actor=${Uri.encodeComponent(profile.did)}'); - }, - ); - } -} - -class _FollowButton extends StatelessWidget { - const _FollowButton({required this.profile, required this.isFollowing}); - - final ProfileView profile; - final bool isFollowing; - - @override - Widget build(BuildContext context) { - if (isFollowing) { - return const OutlinedButton(onPressed: null, child: Text('Following')); - } - - return const FilledButton(onPressed: null, child: Text('Follow')); - } -} diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index 94d4cdc..164440e 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -22,6 +22,7 @@ import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -35,6 +36,8 @@ class MockFeedBloc extends MockBloc implements FeedBloc {} class MockProfileActionRepository extends Mock implements ProfileActionRepository {} +class MockProfileRepository extends Mock implements ProfileRepository {} + class MockSettingsCubit extends MockCubit implements SettingsCubit {} class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} @@ -53,6 +56,7 @@ void main() { late MockFeedBloc feedBloc; late MockSettingsCubit settingsCubit; late MockConnectivityCubit connectivityCubit; + late MockProfileRepository profileRepository; const tokens = AuthTokens( accessToken: 'access', @@ -94,6 +98,7 @@ void main() { feedBloc = MockFeedBloc(); settingsCubit = MockSettingsCubit(); connectivityCubit = MockConnectivityCubit(); + profileRepository = MockProfileRepository(); when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); @@ -229,6 +234,55 @@ void main() { ).called(1); }); + testWidgets('other profiles show a suggested follows tab with loaded suggestions', (tester) async { + useLargeScreen(tester); + const otherProfile = ProfileViewDetailed( + did: 'did:plc:other', + handle: 'other.bsky.social', + displayName: 'Other User', + ); + final suggestions = [ + const ProfileView(did: 'did:plc:suggested', handle: 'suggested.bsky.social', displayName: 'Suggested User'), + ]; + final mockProfileActionRepository = MockProfileActionRepository(); + + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); + whenListen( + profileBloc, + const Stream.empty(), + initialState: const ProfileState.loaded(profile: otherProfile), + ); + when(() => profileRepository.getSuggestedFollows('did:plc:other')).thenAnswer((_) async => suggestions); + + await tester.pumpWidget( + MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: profileRepository), + RepositoryProvider.value(value: mockProfileActionRepository), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: connectivityCubit), + BlocProvider.value(value: settingsCubit), + ], + child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('SUGGESTED'), findsOneWidget); + + await tester.tap(find.text('SUGGESTED')); + await tester.pumpAndSettle(); + + verify(() => profileRepository.getSuggestedFollows('did:plc:other')).called(1); + expect(find.text('Suggested User'), findsOneWidget); + }); + testWidgets('compose FAB on other profiles prefills the mentioned handle', (tester) async { useLargeScreen(tester); const otherProfile = ProfileViewDetailed( diff --git a/test/features/profile/presentation/widgets/suggested_follows_sheet_test.dart b/test/features/profile/presentation/widgets/suggested_follows_sheet_test.dart index 8ae38a4..3ffcd40 100644 --- a/test/features/profile/presentation/widgets/suggested_follows_sheet_test.dart +++ b/test/features/profile/presentation/widgets/suggested_follows_sheet_test.dart @@ -1,30 +1,55 @@ import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; +import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_sheet.dart'; import 'package:mocktail/mocktail.dart'; class MockSuggestedFollowsCubit extends MockCubit implements SuggestedFollowsCubit {} +class MockProfileActionRepository extends Mock implements ProfileActionRepository {} + ProfileView _profile(String did, {String? displayName}) => ProfileView(did: did, handle: '$did.bsky.social', displayName: displayName, indexedAt: DateTime.utc(2026)); void main() { late MockSuggestedFollowsCubit cubit; + late MockProfileActionRepository profileActionRepository; setUp(() { cubit = MockSuggestedFollowsCubit(); + profileActionRepository = MockProfileActionRepository(); }); Widget buildSubject() { - return MaterialApp( - home: Scaffold( - body: BlocProvider.value(value: cubit, child: const SuggestedFollowsSheet()), - ), + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => RepositoryProvider.value( + value: profileActionRepository, + child: Scaffold( + body: BlocProvider.value( + value: cubit, + child: const SuggestedFollowsSheet(actor: 'did:plc:target'), + ), + ), + ), + routes: [ + GoRoute( + path: 'profile/view', + builder: (context, state) => Scaffold(body: Text('Profile View ${state.uri.queryParameters['actor']}')), + ), + ], + ), + ], ); + + return MaterialApp.router(routerConfig: router); } testWidgets('shows loading indicator when state is loading', (tester) async { @@ -88,6 +113,7 @@ void main() { testWidgets('shows Follow button for unfollowed profiles', (tester) async { final profiles = [_profile('did:plc:bob', displayName: 'Bob Builder')]; + when(() => profileActionRepository.followActor(did: 'did:plc:bob')).thenAnswer((_) async => 'at://follow/bob'); when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); whenListen( cubit, @@ -101,6 +127,44 @@ void main() { expect(find.text('Follow'), findsOneWidget); }); + testWidgets('follow button toggles using profile action repository', (tester) async { + final profiles = [_profile('did:plc:bob', displayName: 'Bob Builder')]; + when(() => profileActionRepository.followActor(did: 'did:plc:bob')).thenAnswer((_) async => 'at://follow/bob'); + when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); + whenListen( + cubit, + const Stream.empty(), + initialState: SuggestedFollowsState.loaded(profiles), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.text('Follow')); + await tester.pumpAndSettle(); + + verify(() => profileActionRepository.followActor(did: 'did:plc:bob')).called(1); + expect(find.text('Following'), findsOneWidget); + }); + + testWidgets('tapping a suggestion navigates to /profile/view', (tester) async { + final profiles = [_profile('did:plc:bob', displayName: 'Bob Builder')]; + when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); + whenListen( + cubit, + const Stream.empty(), + initialState: SuggestedFollowsState.loaded(profiles), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.text('Bob Builder')); + await tester.pumpAndSettle(); + + expect(find.text('Profile View did:plc:bob'), findsOneWidget); + }); + testWidgets('shows sheet title', (tester) async { when(() => cubit.state).thenReturn(const SuggestedFollowsState.loaded([])); whenListen( diff --git a/test/features/search/presentation/search_screen_test.dart b/test/features/search/presentation/search_screen_test.dart index afb7ae0..4f9fd57 100644 --- a/test/features/search/presentation/search_screen_test.dart +++ b/test/features/search/presentation/search_screen_test.dart @@ -418,7 +418,6 @@ void main() { ), ).thenAnswer((_) async {}); - final encodedUri = Uri.encodeComponent(packUri.toString()); final router = GoRouter( routes: [ GoRoute( @@ -454,7 +453,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('starterpack:${packUri.toString()}'), findsOneWidget); - expect(find.text(encodedUri), findsOneWidget); }); }); }