diff --git a/lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart b/lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart index 856518d9..909ce454 100644 --- a/lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart +++ b/lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart @@ -28,10 +28,18 @@ class AppLeadingButton extends StatelessWidget { final theme = Theme.of(context); final iconColor = color ?? theme.textTheme.titleLarge?.color; - return IconButton( - onPressed: action, - icon: AppIcons.chevronleft(color: iconColor), - tooltip: tooltip ?? 'Back', + return SizedBox( + width: 40, + height: 40, + child: Tooltip( + message: tooltip ?? 'Back', + child: GestureDetector( + onTap: action, + child: Center( + child: AppIcons.chevronleft(color: iconColor, size: 35), + ), + ), + ), ); }, ); diff --git a/lib/src/core/design_system/components/molecules/known_interactions_bar.dart b/lib/src/core/design_system/components/molecules/known_interactions_bar.dart index 67814fb7..ee58745d 100644 --- a/lib/src/core/design_system/components/molecules/known_interactions_bar.dart +++ b/lib/src/core/design_system/components/molecules/known_interactions_bar.dart @@ -104,7 +104,10 @@ class _InteractionPill extends StatelessWidget { children: [ icon, const SizedBox(width: 6), - AvatarStack(avatars: avatars), + AvatarStack( + avatars: avatars, + largeSize: 32, + ), ], ), ), diff --git a/lib/src/core/design_system/components/molecules/profile_action_buttons.dart b/lib/src/core/design_system/components/molecules/profile_action_buttons.dart index c3b98676..c30cb9be 100644 --- a/lib/src/core/design_system/components/molecules/profile_action_buttons.dart +++ b/lib/src/core/design_system/components/molecules/profile_action_buttons.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/text_button.dart' - as ds; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; import 'package:spark/src/core/design_system/components/atoms/toggles/follow_button.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/design_system/tokens/typography.dart'; class ProfileActionButtons extends StatelessWidget { const ProfileActionButtons({ @@ -13,7 +14,6 @@ class ProfileActionButtons extends StatelessWidget { this.onFollowTap, this.onUnfollowTap, this.onUnblockTap, - this.onShareTap, }); final bool isCurrentUser; @@ -23,7 +23,6 @@ class ProfileActionButtons extends StatelessWidget { final VoidCallback? onFollowTap; final VoidCallback? onUnfollowTap; final VoidCallback? onUnblockTap; - final VoidCallback? onShareTap; @override Widget build(BuildContext context) { @@ -31,18 +30,10 @@ class ProfileActionButtons extends StatelessWidget { return Row( children: [ Expanded( - child: ds.TextButton( - label: 'Edit', + child: _EditButton( onTap: onEditTap, ), ), - const SizedBox(width: 8), - Expanded( - child: ds.TextButton( - label: 'Share Profile', - onTap: onShareTap, - ), - ), ], ); } @@ -65,3 +56,45 @@ class ProfileActionButtons extends StatelessWidget { ); } } + +/// Edit button that matches the FollowButton's following state style +class _EditButton extends StatelessWidget { + const _EditButton({ + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return InteractivePressable( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Container( + width: double.infinity, + height: 36, + decoration: BoxDecoration( + color: isDark ? AppColors.darkGreyButton : AppColors.lightGreyButton, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.fromBorderSide( + BorderSide( + color: isDark + ? AppColors.grey700.withValues(alpha: 0.3) + : AppColors.grey100.withValues(alpha: 0.3), + width: 1.14667, + ), + ), + ), + child: const Align( + child: Text( + 'Edit', + textAlign: TextAlign.center, + style: AppTypography.textSmallMedium, + ), + ), + ), + ); + } +} diff --git a/lib/src/core/design_system/components/molecules/profile_avatar.dart b/lib/src/core/design_system/components/molecules/profile_avatar.dart index e0d35a20..93aae715 100644 --- a/lib/src/core/design_system/components/molecules/profile_avatar.dart +++ b/lib/src/core/design_system/components/molecules/profile_avatar.dart @@ -63,12 +63,6 @@ class ProfileAvatar extends StatelessWidget { ? AppColors.darkPurple : AppColors.lightLavender, shape: BoxShape.circle, - border: Border.all( - color: isDarkMode - ? AppColors.darkPurple - : AppColors.lightLavender, - width: 2, - ), ), child: hasStories ? Container( diff --git a/lib/src/core/design_system/templates/info_bar_template.dart b/lib/src/core/design_system/templates/info_bar_template.dart index f9a69f03..bc131746 100644 --- a/lib/src/core/design_system/templates/info_bar_template.dart +++ b/lib/src/core/design_system/templates/info_bar_template.dart @@ -71,41 +71,6 @@ class _InfoBarTemplateState extends State widget.onDescriptionExpandToggle?.call(_isDescriptionExpanded); } - Widget _buildHandleText(BuildContext context, String handle) { - const color = AppColors.greyWhite; - - final parts = handle.split('.'); - if (parts.length == 1) { - return GestureDetector( - onTap: widget.onHandleTap, - child: Text( - '@$handle', - style: AppTypography.textSmallThin.copyWith(color: color), - ), - ); - } - - final first = parts.first; - final rest = parts.sublist(1).join('.'); - return GestureDetector( - onTap: widget.onHandleTap, - child: RichText( - text: TextSpan( - style: AppTypography.textSmallThin.copyWith(color: color), - children: [ - TextSpan(text: '@$first'), - TextSpan( - text: '.$rest', - style: AppTypography.textSmallThin.copyWith( - color: color.withAlpha(128), - ), - ), - ], - ), - ), - ); - } - @override Widget build(BuildContext context) { const textColor = AppColors.greyWhite; @@ -117,43 +82,35 @@ class _InfoBarTemplateState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.all(8), - child: ProfileAvatar( - avatarUrl: widget.avatarUrl, - displayName: widget.displayName, - size: 36, - onTap: widget.onAvatarTap ?? widget.onTitleTap, - ), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: widget.onTitleTap, - child: Text( - widget.displayName, - style: AppTypography.textMediumBold.copyWith( - color: textColor, + child: GestureDetector( + onTap: widget.onTitleTap, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: ProfileAvatar( + avatarUrl: widget.avatarUrl, + displayName: widget.displayName, + size: 32, + onTap: widget.onAvatarTap ?? widget.onTitleTap, ), - overflow: TextOverflow.ellipsis, ), - ), - const SizedBox(height: 2), - Row( - children: [ - Expanded(child: _buildHandleText(context, widget.handle)), - if (widget.altAvailable) _AltPill(onTap: widget.onAltTap), - ], - ), - ], + + Expanded( + child: Text( + widget.handle, + style: AppTypography.textMediumBold.copyWith( + color: textColor, + fontSize: 17, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.altAvailable) _AltPill(onTap: widget.onAltTap), + ], + ), ), ), if (widget.showFollowButton) diff --git a/lib/src/core/design_system/templates/profile_page_template.dart b/lib/src/core/design_system/templates/profile_page_template.dart index 5f3af52e..c9d8333b 100644 --- a/lib/src/core/design_system/templates/profile_page_template.dart +++ b/lib/src/core/design_system/templates/profile_page_template.dart @@ -7,7 +7,6 @@ import 'package:spark/src/core/design_system/components/molecules/profile_avatar import 'package:spark/src/core/design_system/components/molecules/profile_info.dart'; import 'package:spark/src/core/design_system/components/molecules/profile_stats.dart'; import 'package:spark/src/core/design_system/components/organisms/sticky_profile_tab_bar.dart'; -import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; class ProfilePageTemplate extends StatelessWidget { @@ -35,7 +34,6 @@ class ProfilePageTemplate extends StatelessWidget { this.onFollowTap, this.onUnfollowTap, this.onUnblockTap, - this.onShareTap, this.onEarlySupporterTap, this.onMentionTap, this.onAddStoryTap, @@ -69,7 +67,6 @@ class ProfilePageTemplate extends StatelessWidget { final VoidCallback? onFollowTap; final VoidCallback? onUnfollowTap; final VoidCallback? onUnblockTap; - final VoidCallback? onShareTap; final VoidCallback? onEarlySupporterTap; final Function(String username)? onMentionTap; final VoidCallback? onAddStoryTap; @@ -88,7 +85,8 @@ class ProfilePageTemplate extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, + centerTitle: isCurrentUser, + leadingWidth: 40, title: appBarTitle != null ? Text( appBarTitle!, @@ -107,6 +105,7 @@ class ProfilePageTemplate extends StatelessWidget { }, child: CustomScrollView( controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter( child: Skeletonizer( @@ -132,7 +131,6 @@ class ProfilePageTemplate extends StatelessWidget { onFollowTap: onFollowTap, onUnfollowTap: onUnfollowTap, onUnblockTap: onUnblockTap, - onShareTap: onShareTap, onEarlySupporterTap: onEarlySupporterTap, onMentionTap: onMentionTap, onAddStoryTap: onAddStoryTap, @@ -177,7 +175,6 @@ class _ProfileHeaderSection extends StatelessWidget { this.onFollowTap, this.onUnfollowTap, this.onUnblockTap, - this.onShareTap, this.onEarlySupporterTap, this.onMentionTap, this.onAddStoryTap, @@ -203,44 +200,10 @@ class _ProfileHeaderSection extends StatelessWidget { final VoidCallback? onFollowTap; final VoidCallback? onUnfollowTap; final VoidCallback? onUnblockTap; - final VoidCallback? onShareTap; final VoidCallback? onEarlySupporterTap; final Function(String username)? onMentionTap; final VoidCallback? onAddStoryTap; - Widget _buildHandleText(BuildContext context, String handle) { - final theme = Theme.of(context); - final textColor = theme.textTheme.bodyMedium?.color ?? AppColors.greyWhite; - - final parts = handle.split('.'); - if (parts.length == 1) { - return Text( - '@$handle', - style: AppTypography.textSmallThin.copyWith(color: textColor), - ); - } - - final firstPart = parts[0]; - final remainingPart = parts.sublist(1).join('.'); - - return RichText( - text: TextSpan( - style: AppTypography.textSmallThin.copyWith( - color: textColor, - ), - children: [ - TextSpan(text: '@$firstPart'), - TextSpan( - text: '.$remainingPart', - style: AppTypography.textSmallThin.copyWith( - color: textColor.withAlpha(128), - ), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { return Padding( @@ -256,6 +219,7 @@ class _ProfileHeaderSection extends StatelessWidget { avatarUrl: avatarUrl, displayName: displayName, hasStories: hasStories, + size: 80, onTap: onAvatarTap, showAddButton: isCurrentUser, onAddTap: onAddStoryTap, @@ -285,10 +249,6 @@ class _ProfileHeaderSection extends StatelessWidget { ), ], ), - const SizedBox(height: 4), - Skeleton.keep( - child: _buildHandleText(context, handle), - ), const SizedBox(height: 10), ProfileStats( postsCount: postsCount, @@ -323,7 +283,6 @@ class _ProfileHeaderSection extends StatelessWidget { onFollowTap: onFollowTap, onUnfollowTap: onUnfollowTap, onUnblockTap: onUnblockTap, - onShareTap: onShareTap, ), ), ], diff --git a/lib/src/core/network/atproto/data/repositories/actor_repository.dart b/lib/src/core/network/atproto/data/repositories/actor_repository.dart index eedcb9fe..e6336e68 100644 --- a/lib/src/core/network/atproto/data/repositories/actor_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/actor_repository.dart @@ -6,12 +6,17 @@ abstract class ActorRepository { /// Get a profile by DID /// /// [did] The DID of the profile to get - Future getProfile(String did); + /// [useBluesky] Whether to use Bluesky API instead of Spark (default false) + Future getProfile(String did, {bool useBluesky = false}); /// Get multiple profiles by their DIDs /// /// [dids] A list of DIDs to fetch profiles for - Future> getProfiles(List dids); + /// [useBluesky] Whether to use Bluesky API instead of Spark (default false) + Future> getProfiles( + List dids, { + bool useBluesky = false, + }); /// Search actors by query string. /// diff --git a/lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart index 06141d0a..28db7fc0 100644 --- a/lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart @@ -22,8 +22,11 @@ class ActorRepositoryImpl implements ActorRepository { ); @override - Future getProfile(String did) async { - _logger.d('Getting profile for DID: $did'); + Future getProfile( + String did, { + bool useBluesky = false, + }) async { + _logger.d('Getting profile for DID: $did, useBluesky: $useBluesky'); return _client.executeWithRetry(() async { if (!_client.authRepository.isAuthenticated) { _logger.w('Not authenticated'); @@ -35,35 +38,35 @@ class ActorRepositoryImpl implements ActorRepository { _logger.e('AtProto not initialized'); throw Exception('AtProto not initialized'); } - try { - final result = await atproto.get( - NSID.parse('so.sprk.actor.getProfile'), - parameters: {'actor': did}, - headers: {'atproto-proxy': _client.sprkDid}, - to: (jsonMap) => jsonMap, - adaptor: (uint8) => - jsonDecode(utf8.decode(uint8 as List)) - as Map, - ); - return ProfileViewDetailed.fromJson( - result.data as Map, - ); - } catch (e) { - _logger - ..e('Failed to retrieve profile for DID: $did', error: e) - ..i('Trying to get profile from bluesky'); + + // Use Bluesky API if explicitly requested + if (useBluesky) { final oauthSession = atproto.oAuthSession; if (oauthSession == null) { throw Exception('No OAuth session available'); } - final bluesky = bsky.Bluesky.fromOAuthSession(oauthSession); + final blueskyClient = bsky.Bluesky.fromOAuthSession(oauthSession); final profile = await bskyActorAdapter.getProfileFromBluesky( - bluesky, + blueskyClient, did, ); - _logger.d('Profile retrieved successfully from bluesky'); + _logger.d('Profile retrieved successfully from Bluesky'); return profile; } + + // Use Spark API (no fallback) + final result = await atproto.get( + NSID.parse('so.sprk.actor.getProfile'), + parameters: {'actor': did}, + headers: {'atproto-proxy': _client.sprkDid}, + to: (jsonMap) => jsonMap, + adaptor: (uint8) => + jsonDecode(utf8.decode(uint8 as List)) as Map, + ); + _logger.d('Profile retrieved successfully from Spark'); + return ProfileViewDetailed.fromJson( + result.data as Map, + ); }); } @@ -176,9 +179,12 @@ class ActorRepositoryImpl implements ActorRepository { } @override - Future> getProfiles(List dids) { + Future> getProfiles( + List dids, { + bool useBluesky = false, + }) { return _client.executeWithRetry(() async { - _logger.d('Getting profiles for DIDs: $dids'); + _logger.d('Getting profiles for DIDs: $dids, useBluesky: $useBluesky'); if (dids.isEmpty) { _logger.w('No DIDs provided, returning empty list'); return []; @@ -193,38 +199,38 @@ class ActorRepositoryImpl implements ActorRepository { _logger.e('AtProto not initialized'); throw Exception('AtProto not initialized'); } - try { - final result = await atproto.get( - NSID.parse('so.sprk.actor.getProfiles'), - parameters: {'actors': dids}, - headers: {'atproto-proxy': _client.sprkDid}, - to: (jsonMap) => jsonMap, - adaptor: (uint8) => - jsonDecode(utf8.decode(uint8 as List)) - as Map, - ); - return (result.data['profiles']! as List) - .map( - (json) => - ProfileViewDetailed.fromJson(json as Map), - ) - .toList(); - } catch (e) { - _logger - ..e('Failed to retrieve profile for DIDs: $dids', error: e) - ..i('Trying to get profiles from bluesky'); + + // Use Bluesky API if explicitly requested + if (useBluesky) { final oauthSession = atproto.oAuthSession; if (oauthSession == null) { throw Exception('No OAuth session available'); } - final bluesky = bsky.Bluesky.fromOAuthSession(oauthSession); + final blueskyClient = bsky.Bluesky.fromOAuthSession(oauthSession); final profiles = await bskyActorAdapter.getProfilesFromBluesky( - bluesky, + blueskyClient, dids, ); - _logger.d('Profiles retrieved successfully from bluesky'); + _logger.d('Profiles retrieved successfully from Bluesky'); return profiles; } + + // Use Spark API (no fallback) + final result = await atproto.get( + NSID.parse('so.sprk.actor.getProfiles'), + parameters: {'actors': dids}, + headers: {'atproto-proxy': _client.sprkDid}, + to: (jsonMap) => jsonMap, + adaptor: (uint8) => + jsonDecode(utf8.decode(uint8 as List)) as Map, + ); + _logger.d('Profiles retrieved successfully from Spark'); + return (result.data['profiles']! as List) + .map( + (json) => + ProfileViewDetailed.fromJson(json as Map), + ) + .toList(); }); } } diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository.dart b/lib/src/core/network/atproto/data/repositories/feed_repository.dart index c93eaeb9..04af5829 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository.dart @@ -222,9 +222,11 @@ abstract class FeedRepository { /// [actor] The at-identifier of the actor (handle or DID) /// [limit] The number of items to return (default 50, max 100) /// [cursor] Pagination cursor for the next set of results + /// [bluesky] Whether to fetch from Bluesky API instead of Spark Future<({List posts, String? cursor})> getActorReposts( String actor, { int limit = 50, String? cursor, + bool bluesky = false, }); } diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart index e7b9bae8..6597f72f 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart @@ -1527,11 +1527,17 @@ class FeedRepositoryImpl implements FeedRepository { String actor, { int limit = 50, String? cursor, + bool bluesky = false, }) async { _logger.d( - 'Getting actor reposts for actor: $actor, limit: $limit, cursor: $cursor', + 'Getting actor reposts for actor: $actor, limit: $limit, ' + 'cursor: $cursor, bluesky: $bluesky', ); + if (bluesky) { + return _getActorRepostsFromBluesky(actor, limit: limit, cursor: cursor); + } + return _client.executeWithRetry(() async { if (!_client.authRepository.isAuthenticated) { _logger.w('Not authenticated'); @@ -1579,6 +1585,21 @@ class FeedRepositoryImpl implements FeedRepository { }); } + /// Get actor reposts from Bluesky API + /// Note: Bluesky doesn't have a direct getActorReposts endpoint, + /// so we return an empty result in Bluesky mode. + Future<({List posts, String? cursor})> + _getActorRepostsFromBluesky( + String actor, { + required int limit, + required String? cursor, + }) async { + _logger.w( + 'getActorReposts is not available for Bluesky API, returning empty', + ); + return (posts: [], cursor: null); + } + /// Helper method to determine content type based on file extension String _getContentType(String videoPath) { final extension = path.extension(videoPath).toLowerCase(); diff --git a/lib/src/core/network/atproto/data/repositories/graph_repository.dart b/lib/src/core/network/atproto/data/repositories/graph_repository.dart index 70d2ecae..5c42f224 100644 --- a/lib/src/core/network/atproto/data/repositories/graph_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/graph_repository.dart @@ -18,7 +18,8 @@ abstract class GraphRepository { /// Follow a user /// /// [did] The DID of the user to follow - Future followUser(String did); + /// [bsky] Whether to use Bluesky follow records instead of Spark + Future followUser(String did, {bool bsky = false}); /// Unfollow a user /// @@ -29,8 +30,13 @@ abstract class GraphRepository { /// /// [did] The DID of the user to toggle follow for /// [currentFollowUri] The follow URI if following, null if not + /// [bsky] Whether to use Bluesky follow records instead of Spark /// Returns the follow URI if now following, null if unfollowed - Future toggleFollow(String did, AtUri? currentFollowUri); + Future toggleFollow( + String did, + AtUri? currentFollowUri, { + bool bsky = false, + }); /// Get blocks for a DID /// diff --git a/lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart index 96a977ac..4ff53f18 100644 --- a/lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart @@ -93,8 +93,8 @@ class GraphRepositoryImpl implements GraphRepository { } @override - Future followUser(String did) async { - _logger.d('Following user with DID: $did'); + Future followUser(String did, {bool bsky = false}) async { + _logger.d('Following user with DID: $did, bsky: $bsky'); return _client.executeWithRetry(() async { if (!_client.authRepository.isAuthenticated) { _logger.w('Not authenticated'); @@ -113,7 +113,9 @@ class GraphRepositoryImpl implements GraphRepository { throw Exception('Session DID not available'); } - const collection = 'so.sprk.graph.follow'; + final collection = bsky + ? 'app.bsky.graph.follow' + : 'so.sprk.graph.follow'; try { _logger.d('Checking if already following user: $did'); @@ -164,8 +166,15 @@ class GraphRepositoryImpl implements GraphRepository { } @override - Future toggleFollow(String did, AtUri? currentFollowUri) async { - _logger.d('Toggling follow for DID: $did, current URI: $currentFollowUri'); + Future toggleFollow( + String did, + AtUri? currentFollowUri, { + bool bsky = false, + }) async { + _logger.d( + 'Toggling follow for DID: $did, current URI: $currentFollowUri, ' + 'bsky: $bsky', + ); return _client.executeWithRetry(() async { if (currentFollowUri != null) { // User is following, so unfollow @@ -174,7 +183,7 @@ class GraphRepositoryImpl implements GraphRepository { return null; } else { // User is not following, so follow - final response = await followUser(did); + final response = await followUser(did, bsky: bsky); _logger.i('User followed via toggle: ${response.uri}'); return response.uri; } diff --git a/lib/src/features/comments/ui/widgets/comment_item.dart b/lib/src/features/comments/ui/widgets/comment_item.dart index cadb3cb0..c5fc06da 100644 --- a/lib/src/features/comments/ui/widgets/comment_item.dart +++ b/lib/src/features/comments/ui/widgets/comment_item.dart @@ -46,10 +46,14 @@ class _CommentItemState extends ConsumerState { void _navigateToProfile() { final author = commentState.thread.post.author; + final isBskyPost = widget.mainPostUri.collection.toString().startsWith( + 'app.bsky', + ); context.router.push( ProfileRoute( did: author.did, initialProfile: author, + bsky: isBskyPost, ), ); } diff --git a/lib/src/features/feed/ui/pages/standalone_post_page.dart b/lib/src/features/feed/ui/pages/standalone_post_page.dart index 07f0a67d..7791306b 100644 --- a/lib/src/features/feed/ui/pages/standalone_post_page.dart +++ b/lib/src/features/feed/ui/pages/standalone_post_page.dart @@ -183,10 +183,16 @@ class _StandalonePostPageState extends ConsumerState { onUsernameTap: () { // Pause video before navigating to profile _videoPlayerKey.currentState?.pauseVideo(); + final isBskyPost = postData.uri.collection + .toString() + .startsWith( + 'app.bsky', + ); context.router.push( ProfileRoute( did: postData.author.did, initialProfile: postData.author, + bsky: isBskyPost, ), ); }, diff --git a/lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart b/lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart index 5488d95d..2ee78590 100644 --- a/lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart +++ b/lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart @@ -370,8 +370,8 @@ class SideActionBarState extends ConsumerState { } else { final profileUri = AtUri.parse('at://${currentPost.author.did}'); ref - ..invalidate(profileFeedProvider(profileUri, false)) - ..invalidate(profileFeedProvider(profileUri, true)); + ..invalidate(profileFeedProvider(profileUri, false, false)) + ..invalidate(profileFeedProvider(profileUri, true, false)); } } catch (e) { // Error handling - snackbar removed diff --git a/lib/src/features/feed/ui/widgets/post/feed_post_widget.dart b/lib/src/features/feed/ui/widgets/post/feed_post_widget.dart index b17172e5..9837fbaa 100644 --- a/lib/src/features/feed/ui/widgets/post/feed_post_widget.dart +++ b/lib/src/features/feed/ui/widgets/post/feed_post_widget.dart @@ -309,10 +309,16 @@ class _FeedPostWidgetState extends ConsumerState { }, onUsernameTap: () { _videoPlayerKey.currentState?.pauseVideo(); + final isBskyPost = currentPost.uri.collection + .toString() + .startsWith( + 'app.bsky', + ); context.router.push( ProfileRoute( did: currentPost.author.did, initialProfile: currentPost.author, + bsky: isBskyPost, ), ); }, diff --git a/lib/src/features/posting/ui/pages/image_review_page.dart b/lib/src/features/posting/ui/pages/image_review_page.dart index b614bbfa..4dcdda36 100644 --- a/lib/src/features/posting/ui/pages/image_review_page.dart +++ b/lib/src/features/posting/ui/pages/image_review_page.dart @@ -197,10 +197,18 @@ class _ImageReviewPageState extends ConsumerState { if (did != null) { ref ..invalidate( - profileFeedProvider(AtUri.parse('at://$did'), false), + profileFeedProvider( + AtUri.parse('at://$did'), + false, + false, + ), ) ..invalidate( - profileFeedProvider(AtUri.parse('at://$did'), true), + profileFeedProvider( + AtUri.parse('at://$did'), + true, + false, + ), ); } if (!widget.storyMode) { diff --git a/lib/src/features/posting/ui/pages/video_review_page.dart b/lib/src/features/posting/ui/pages/video_review_page.dart index d9ef5866..b67af4ae 100644 --- a/lib/src/features/posting/ui/pages/video_review_page.dart +++ b/lib/src/features/posting/ui/pages/video_review_page.dart @@ -111,8 +111,12 @@ class _VideoReviewPageState extends ConsumerState { final did = ref.read(currentDidProvider); if (did != null) { ref - ..invalidate(profileFeedProvider(AtUri.parse('at://$did'), false)) - ..invalidate(profileFeedProvider(AtUri.parse('at://$did'), true)); + ..invalidate( + profileFeedProvider(AtUri.parse('at://$did'), false, false), + ) + ..invalidate( + profileFeedProvider(AtUri.parse('at://$did'), true, false), + ); } if (postRef == null) { return; diff --git a/lib/src/features/profile/providers/profile_feed_provider.dart b/lib/src/features/profile/providers/profile_feed_provider.dart index 284b83fc..5f36c786 100644 --- a/lib/src/features/profile/providers/profile_feed_provider.dart +++ b/lib/src/features/profile/providers/profile_feed_provider.dart @@ -21,7 +21,11 @@ class ProfileFeed extends _$ProfileFeed { bool _isLoading = false; @override - Future build(AtUri profileUri, bool videosOnly) async { + Future build( + AtUri profileUri, + bool videosOnly, + bool bsky, + ) async { try { final result = await _loadUnifiedFeed( profileUri: profileUri, @@ -40,8 +44,7 @@ class ProfileFeed extends _$ProfileFeed { } } - /// Load author feed from Spark first, falling back to Bluesky if Spark fails. - /// This mirrors the profile loading behavior where we only show one source. + /// Load author feed from API (Spark by default, Bluesky if bsky=true). Future _loadUnifiedFeed({ required AtUri profileUri, required String? sparkCursor, @@ -58,16 +61,16 @@ class ProfileFeed extends _$ProfileFeed { final newPosts = []; - // Fetch from Spark API (internally falls back to Bluesky if Spark fails) - // This mirrors profile loading behavior + // Fetch from the specified API (Spark by default, Bluesky if bsky=true) final result = await _fetchFromSource( (cursor) => _feedRepository.getAuthorFeed( profileUri, limit: ProfileFeedState.fetchLimit, cursor: cursor, + bluesky: bsky, ), sparkCursor, - 'AuthorFeed', + bsky ? 'BlueskyAuthorFeed' : 'SparkAuthorFeed', ); for (final feedViewPost in result.posts) { diff --git a/lib/src/features/profile/providers/profile_provider.dart b/lib/src/features/profile/providers/profile_provider.dart index 336ef728..e0ca7121 100644 --- a/lib/src/features/profile/providers/profile_provider.dart +++ b/lib/src/features/profile/providers/profile_provider.dart @@ -29,8 +29,8 @@ class ProfileNotifier extends _$ProfileNotifier { late final SparkLogger logger; @override - Future build({String? did}) async { - logger.d('Building ProfileNotifier for did: $did'); + Future build({String? did, bool bsky = false}) async { + logger.d('Building ProfileNotifier for did: $did, bsky: $bsky'); final initialState = ProfileState(currentViewDid: did); await loadProfileData(did, initialState); // Return the state value if available, otherwise return initial state @@ -59,8 +59,11 @@ class ProfileNotifier extends _$ProfileNotifier { } try { - logger.d('Loading profile for DID: $effectiveDid'); - final profile = await actorRepository.getProfile(effectiveDid); + logger.d('Loading profile for DID: $effectiveDid, bsky: $bsky'); + final profile = await actorRepository.getProfile( + effectiveDid, + useBluesky: bsky, + ); logger.d( 'Profile loaded successfully for $effectiveDid: ${profile.handle}', @@ -86,7 +89,7 @@ class ProfileNotifier extends _$ProfileNotifier { Future refreshProfile() async { final currentProfileState = state.asData?.value; final currentDid = currentProfileState?.currentViewDid; - logger.d('Refreshing profile for DID: $currentDid'); + logger.d('Refreshing profile for DID: $currentDid, bsky: $bsky'); final didToRefresh = currentDid ?? authRepository.did; if (didToRefresh == null) { @@ -100,8 +103,8 @@ class ProfileNotifier extends _$ProfileNotifier { // Invalidate feed providers to force a rebuild rather than calling // notifier methods directly, which can fail if providers are disposed ref - ..invalidate(profileFeedProvider(profileUri, true)) - ..invalidate(profileFeedProvider(profileUri, false)); + ..invalidate(profileFeedProvider(profileUri, true, bsky)) + ..invalidate(profileFeedProvider(profileUri, false, bsky)); // Load profile data await loadProfileData( @@ -162,6 +165,7 @@ class ProfileNotifier extends _$ProfileNotifier { final newFollowUriResult = await sprkRepository.graph.toggleFollow( profile.did, profile.viewer?.following, + bsky: bsky, ); if (newFollowUriResult != null) { @@ -173,7 +177,14 @@ class ProfileNotifier extends _$ProfileNotifier { logger.i('Successfully unfollowed ${profile.did}.'); } - // Update state optimistically first + // Optimistically update the UI: follow state and follower count + final currentFollowersCount = profile.followersCount ?? 0; + + // Update follower count: increment on follow, decrement on unfollow + final newFollowersCount = newFollowUriResult != null + ? currentFollowersCount + 1 + : (currentFollowersCount > 0 ? currentFollowersCount - 1 : 0); + final optimisticViewer = profile.viewer?.copyWith( following: newFollowUriResult != null @@ -186,34 +197,13 @@ class ProfileNotifier extends _$ProfileNotifier { : null, ); - final optimisticProfile = profile.copyWith(viewer: optimisticViewer); - state = AsyncData( - originalStateValue.copyWith(profile: optimisticProfile), + final optimisticProfile = profile.copyWith( + viewer: optimisticViewer, + followersCount: newFollowersCount, ); - // Then refresh the profile data in the background to ensure consistency - // Use a small delay to allow backend to propagate changes - // Note: This is intentionally unawaited - we use optimistic updates above - // & refresh in the background. If this fails, optimistic state remains. - unawaited( - Future.delayed(const Duration(milliseconds: 500)).then((_) async { - try { - final refreshedProfile = await actorRepository.getProfile( - profile.did, - ); - - // Only update if state hasn't changed (user hasn't navigated away) - final currentState = state.asData?.value; - if (currentState?.profile?.did == profile.did) { - state = AsyncData( - currentState!.copyWith(profile: refreshedProfile), - ); - } - } catch (e) { - logger.w('Background profile refresh failed: $e'); - // Keep the optimistic state if refresh fails - } - }), + state = AsyncData( + originalStateValue.copyWith(profile: optimisticProfile), ); return newFollowUriResult; @@ -263,7 +253,7 @@ class ProfileNotifier extends _$ProfileNotifier { logger.i('Successfully unblocked ${profile.did}.'); } - // Update state optimistically first + // Optimistically update the UI: block state final optimisticViewer = profile.viewer?.copyWith( blocking: newBlockUriResult != null @@ -281,31 +271,6 @@ class ProfileNotifier extends _$ProfileNotifier { originalStateValue.copyWith(profile: optimisticProfile), ); - // Then refresh the profile data in the background to ensure consistency - // Use a small delay to allow backend to propagate changes - // Note: This is intentionally unawaited - we use optimistic updates above - // & refresh in the background. If this fails, optimistic state remains. - unawaited( - Future.delayed(const Duration(milliseconds: 500)).then((_) async { - try { - final refreshedProfile = await actorRepository.getProfile( - profile.did, - ); - - // Only update if state hasn't changed (user hasn't navigated away) - final currentState = state.asData?.value; - if (currentState?.profile?.did == profile.did) { - state = AsyncData( - currentState!.copyWith(profile: refreshedProfile), - ); - } - } catch (e) { - logger.w('Background profile refresh failed: $e'); - // Keep the optimistic state if refresh fails - } - }), - ); - return newBlockUriResult; } catch (e, s) { logger.e( diff --git a/lib/src/features/profile/providers/profile_reposts_provider.dart b/lib/src/features/profile/providers/profile_reposts_provider.dart index 90c8f86c..5f587f51 100644 --- a/lib/src/features/profile/providers/profile_reposts_provider.dart +++ b/lib/src/features/profile/providers/profile_reposts_provider.dart @@ -22,7 +22,7 @@ class ProfileReposts extends _$ProfileReposts { late final String _actor; @override - Future build(String actor) async { + Future build(String actor, bool bsky) async { _actor = actor; try { final result = await _loadReposts( @@ -40,7 +40,7 @@ class ProfileReposts extends _$ProfileReposts { } } - /// Load reposts from Spark API + /// Load reposts from specified API (Spark by default, Bluesky if bsky=true) Future _loadReposts({ required String actor, required String? cursor, @@ -55,15 +55,16 @@ class ProfileReposts extends _$ProfileReposts { final newPosts = []; - // Fetch from Spark API + // Fetch from the specified API (Spark by default, Bluesky if bsky=true) final result = await _fetchFromSource( (cursor) => _feedRepository.getActorReposts( actor, limit: ProfileFeedState.fetchLimit, cursor: cursor, + bluesky: bsky, ), cursor, - 'ActorReposts', + bsky ? 'BlueskyActorReposts' : 'SparkActorReposts', ); for (final feedViewPost in result.posts) { diff --git a/lib/src/features/profile/ui/pages/profile_page.dart b/lib/src/features/profile/ui/pages/profile_page.dart index cfb07934..8e1ff332 100644 --- a/lib/src/features/profile/ui/pages/profile_page.dart +++ b/lib/src/features/profile/ui/pages/profile_page.dart @@ -34,6 +34,7 @@ class ProfilePage extends ConsumerStatefulWidget { const ProfilePage({ @PathParam('did') required this.did, this.initialProfile, + this.bsky = false, super.key, }); final String did; @@ -42,6 +43,10 @@ class ProfilePage extends ConsumerStatefulWidget { // Can be partially filled - only did & handle required in ProfileViewBasic. final actor_models.ProfileViewBasic? initialProfile; + /// Whether to use Bluesky API instead of Spark API. + /// Defaults to false (Spark API). + final bool bsky; + @override ConsumerState createState() => _ProfilePageState(); } @@ -77,10 +82,14 @@ class _ProfilePageState extends ConsumerState { _scrollController.position.maxScrollExtent - 500) { final profileUri = AtUri.parse('at://${widget.did}'); if (_activeTabIndex == 0) { - ref.read(profileFeedProvider(profileUri, false).notifier).loadMore(); + ref + .read(profileFeedProvider(profileUri, false, widget.bsky).notifier) + .loadMore(); } else if (_activeTabIndex == 1) { final actor = profileUri.hostname; - ref.read(profileRepostsProvider(actor).notifier).loadMore(); + ref + .read(profileRepostsProvider(actor, widget.bsky).notifier) + .loadMore(); } } } @@ -98,13 +107,16 @@ class _ProfilePageState extends ConsumerState { switch (tabIndex) { case 0: // First tab - default profile grid content (not a route) - tabWidget = ProfileGridTab(profileUri: profileUri); + tabWidget = ProfileGridTab(profileUri: profileUri, bsky: widget.bsky); case 1: // Second tab - reposts - tabWidget = ProfileRepostsTab(profileUri: profileUri); + tabWidget = ProfileRepostsTab( + profileUri: profileUri, + bsky: widget.bsky, + ); default: // Fallback to first tab - tabWidget = ProfileGridTab(profileUri: profileUri); + tabWidget = ProfileGridTab(profileUri: profileUri, bsky: widget.bsky); } // Cache the tab widget to keep it loaded @@ -159,8 +171,12 @@ class _ProfilePageState extends ConsumerState { @override Widget build(BuildContext context) { - final profileStateAsync = ref.watch(profileProvider(did: widget.did)); - final notifier = ref.read(profileProvider(did: widget.did).notifier); + final profileStateAsync = ref.watch( + profileProvider(did: widget.did, bsky: widget.bsky), + ); + final notifier = ref.read( + profileProvider(did: widget.did, bsky: widget.bsky).notifier, + ); final theme = Theme.of(context); final colorScheme = theme.colorScheme; @@ -173,10 +189,10 @@ class _ProfilePageState extends ConsumerState { // Only watch the active tab's provider - lazy load other tabs // This reduces initial load time by not fetching data for hidden tabs if (_activeTabIndex == 0) { - ref.watch(profileFeedProvider(profileUri, false)); + ref.watch(profileFeedProvider(profileUri, false, widget.bsky)); } else if (_activeTabIndex == 1) { final actor = profileUri.hostname; - ref.watch(profileRepostsProvider(actor)); + ref.watch(profileRepostsProvider(actor, widget.bsky)); } // Build slivers for the active tab using cached widget @@ -211,12 +227,18 @@ class _ProfilePageState extends ConsumerState { appBarActions: isCurrentUser ? [ Padding( - padding: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.only(right: 4), child: IconButton( padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, onPressed: () => context.router.push(const SettingsRoute()), - icon: AppIcons.gear(color: colorScheme.onSurface), + icon: AppIcons.gear( + color: colorScheme.onSurface, + size: 28, + ), ), ), ] @@ -280,97 +302,96 @@ class _ProfilePageState extends ConsumerState { _logger.e('Error unblocking profile', error: e); } }, - onShareTap: () => - _logger.i('Share profile tapped for ${profile.did}'), onMentionTap: _handleUsernameTap, onAddStoryTap: isCurrentUser ? () => _handleAddStory(context) : null, - appBarTitle: profile.displayName ?? profile.handle, + appBarTitle: profile.handle, appBarActions: [ if (isCurrentUser) - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - padding: EdgeInsets.zero, - onPressed: () => context.router.push(const SettingsRoute()), - icon: AppIcons.gear(color: colorScheme.onSurface), - ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + onPressed: () => context.router.push(const SettingsRoute()), + icon: AppIcons.gear(color: colorScheme.onSurface, size: 28), ) else - Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( - onTap: () => OptionsPanel.show( + IconButton( + onPressed: () => OptionsPanel.show( + context: context, + onReport: () => showDialog( context: context, - onReport: () => showDialog( - context: context, - useRootNavigator: false, - builder: (dContext) => ReportDialog( - postUri: - 'at://${profile.did}/app.bsky.actor.profile/self', - postCid: profile.did, - onSubmit: (subject, reasonType, reason) async { - try { - await notifier.createReport( - did: profile.did, - reasonType: reasonType, - reason: reason, - ); - } catch (e) { - _logger.e('Error creating report', error: e); - } - }, - ), + useRootNavigator: false, + builder: (dContext) => ReportDialog( + postUri: + 'at://${profile.did}/app.bsky.actor.profile/self', + postCid: profile.did, + onSubmit: (subject, reasonType, reason) async { + try { + await notifier.createReport( + did: profile.did, + reasonType: reasonType, + reason: reason, + ); + } catch (e) { + _logger.e('Error creating report', error: e); + } + }, ), - onBlock: () async { - final wasBlocked = isBlocking(profile.viewer); - - // Show confirmation dialog - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text( - wasBlocked ? 'Unblock User' : 'Block User', - ), - content: Text( - wasBlocked - ? 'Are you sure you want to unblock this user?' - : 'Are you sure you want to block this user? ' - 'You will no longer see their posts.', + ), + onBlock: () async { + final wasBlocked = isBlocking(profile.viewer); + + // Show confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + wasBlocked ? 'Unblock User' : 'Block User', + ), + content: Text( + wasBlocked + ? 'Are you sure you want to unblock this user?' + : 'Are you sure you want to block this user? ' + 'You will no longer see their posts.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom( - foregroundColor: wasBlocked ? null : Colors.red, - ), - child: Text(wasBlocked ? 'Unblock' : 'Block'), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: wasBlocked ? null : Colors.red, ), - ], - ), - ); + child: Text(wasBlocked ? 'Unblock' : 'Block'), + ), + ], + ), + ); - if (confirmed != true) return; - - try { - await notifier.toggleBlock(); - } catch (e) { - _logger.e( - 'Error blocking/unblocking profile', - error: e, - ); - } - }, - isBlocked: isBlocking(profile.viewer), - isProfile: true, - ), - child: Container( - padding: const EdgeInsets.all(8), - child: AppIcons.moreHoriz(color: colorScheme.onSurface), - ), + if (confirmed != true) return; + + try { + await notifier.toggleBlock(); + } catch (e) { + _logger.e( + 'Error blocking/unblocking profile', + error: e, + ); + } + }, + isBlocked: isBlocking(profile.viewer), + isProfile: true, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + icon: AppIcons.moreHoriz( + color: colorScheme.onSurface, + size: 35, ), ), ], @@ -405,13 +426,17 @@ class _ProfilePageState extends ConsumerState { followersCount: '0', followingCount: '0', isCurrentUser: false, - appBarTitle: initial?.displayName ?? initial?.handle, + appBarTitle: initial?.handle ?? 'loading', appBarActions: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: Container( - padding: const EdgeInsets.all(8), - child: AppIcons.moreHoriz(color: colorScheme.onSurface), + IconButton( + onPressed: () {}, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + icon: AppIcons.moreHoriz( + color: colorScheme.onSurface, + size: 28, ), ), ], @@ -442,12 +467,18 @@ class _ProfilePageState extends ConsumerState { appBarActions: isCurrentUser ? [ Padding( - padding: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.only(right: 4), child: IconButton( padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, onPressed: () => context.router.push(const SettingsRoute()), - icon: AppIcons.gear(color: colorScheme.onSurface), + icon: AppIcons.gear( + color: colorScheme.onSurface, + size: 28, + ), ), ), ] diff --git a/lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart b/lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart index 62523279..f7b7c1a7 100644 --- a/lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart +++ b/lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart @@ -19,12 +19,16 @@ class StandaloneProfileFeedPage extends ConsumerStatefulWidget { @PathParam('did') required this.did, required this.videosOnly, required this.initialPostIndex, + this.bsky = false, super.key, }); final String did; final bool videosOnly; final int initialPostIndex; + /// Whether to use Bluesky API instead of Spark API. + final bool bsky; + @override ConsumerState createState() => _StandaloneProfileFeedPageState(); @@ -63,7 +67,7 @@ class _StandaloneProfileFeedPageState } final feedState = ref.watch( - profileFeedProvider(profileAtUri, widget.videosOnly), + profileFeedProvider(profileAtUri, widget.videosOnly, widget.bsky), ); final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -112,6 +116,7 @@ class _StandaloneProfileFeedPageState profileFeedProvider( profileAtUri, widget.videosOnly, + widget.bsky, ).notifier, ) .loadMore(); @@ -156,6 +161,7 @@ class _StandaloneProfileFeedPageState profileFeedProvider( profileAtUri, widget.videosOnly, + widget.bsky, ).notifier, ) .refresh(); diff --git a/lib/src/features/profile/ui/pages/standalone_reposts_feed_page.dart b/lib/src/features/profile/ui/pages/standalone_reposts_feed_page.dart index fc1a2f62..4cd61083 100644 --- a/lib/src/features/profile/ui/pages/standalone_reposts_feed_page.dart +++ b/lib/src/features/profile/ui/pages/standalone_reposts_feed_page.dart @@ -18,11 +18,15 @@ class StandaloneRepostsFeedPage extends ConsumerStatefulWidget { const StandaloneRepostsFeedPage({ @PathParam('did') required this.did, required this.initialPostIndex, + this.bsky = false, super.key, }); final String did; final int initialPostIndex; + /// Whether to use Bluesky API instead of Spark API. + final bool bsky; + @override ConsumerState createState() => _StandaloneRepostsFeedPageState(); @@ -58,7 +62,9 @@ class _StandaloneRepostsFeedPageState }); } - final repostsState = ref.watch(profileRepostsProvider(widget.did)); + final repostsState = ref.watch( + profileRepostsProvider(widget.did, widget.bsky), + ); final bottomPadding = MediaQuery.of(context).padding.bottom; return Scaffold( @@ -102,7 +108,12 @@ class _StandaloneRepostsFeedPageState if (index >= filteredUris.length - 3 && !state.isEndOfNetwork) { ref - .read(profileRepostsProvider(widget.did).notifier) + .read( + profileRepostsProvider( + widget.did, + widget.bsky, + ).notifier, + ) .loadMore(); } }, @@ -143,7 +154,12 @@ class _StandaloneRepostsFeedPageState ElevatedButton( onPressed: () { ref - .read(profileRepostsProvider(widget.did).notifier) + .read( + profileRepostsProvider( + widget.did, + widget.bsky, + ).notifier, + ) .refresh(); }, child: const Text('Retry'), diff --git a/lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart b/lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart index 4b61a04b..a31a3e62 100644 --- a/lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart +++ b/lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart @@ -273,11 +273,18 @@ class _ProfileFeedPostWidgetState extends ConsumerState { if (post.author.did == currentProfileDid) { context.router.maybePop(); } else { + // Check if post is from Bluesky + final isBskyPost = post.uri.collection + .toString() + .startsWith( + 'app.bsky', + ); // Otherwise, navigate to the new profile context.router.push( ProfileRoute( did: post.author.did, initialProfile: post.author, + bsky: isBskyPost, ), ); } diff --git a/lib/src/features/profile/ui/widgets/profile_grid_tab.dart b/lib/src/features/profile/ui/widgets/profile_grid_tab.dart index 71d29c8e..6910ac8d 100644 --- a/lib/src/features/profile/ui/widgets/profile_grid_tab.dart +++ b/lib/src/features/profile/ui/widgets/profile_grid_tab.dart @@ -12,15 +12,21 @@ import 'package:spark/src/features/profile/ui/widgets/profile_tab_base.dart'; class ProfileGridTab extends ProfileTabBase { const ProfileGridTab({ required this.profileUri, + this.bsky = false, super.key, }); final AtUri profileUri; + /// Whether to use Bluesky API instead of Spark API. + final bool bsky; + @override List buildSlivers(BuildContext context, WidgetRef ref) { void onPostTap(BuildContext context, WidgetRef ref, AtUri postUri) { - ref.read(profileFeedProvider(profileUri, false)).whenData((feedState) { + ref.read(profileFeedProvider(profileUri, false, bsky)).whenData(( + feedState, + ) { final filteredUris = feedState.loadedPosts; final postIndex = filteredUris.indexOf(postUri); if (postIndex != -1) { @@ -29,6 +35,7 @@ class ProfileGridTab extends ProfileTabBase { did: profileUri.hostname, videosOnly: false, initialPostIndex: postIndex, + bsky: bsky, ), ); } else { @@ -46,6 +53,7 @@ class ProfileGridTab extends ProfileTabBase { videosOnly: false, both: true, onPostTap: onPostTap, + bsky: bsky, ); } diff --git a/lib/src/features/profile/ui/widgets/profile_grid_widget.dart b/lib/src/features/profile/ui/widgets/profile_grid_widget.dart index c1ddf8d5..4a37f4f3 100644 --- a/lib/src/features/profile/ui/widgets/profile_grid_widget.dart +++ b/lib/src/features/profile/ui/widgets/profile_grid_widget.dart @@ -16,8 +16,11 @@ List buildProfileGridSlivers({ required bool videosOnly, required Function(BuildContext, WidgetRef, AtUri) onPostTap, bool both = false, + bool bsky = false, }) { - final feedState = ref.watch(profileFeedProvider(profileUri, videosOnly)); + final feedState = ref.watch( + profileFeedProvider(profileUri, videosOnly, bsky), + ); return feedState.when( data: (state) { @@ -124,7 +127,13 @@ List buildProfileGridSlivers({ const SizedBox(height: 16), ElevatedButton( onPressed: () => ref - .read(profileFeedProvider(profileUri, videosOnly).notifier) + .read( + profileFeedProvider( + profileUri, + videosOnly, + bsky, + ).notifier, + ) .refresh(), child: const Text('Retry'), ), diff --git a/lib/src/features/profile/ui/widgets/profile_reposts_tab.dart b/lib/src/features/profile/ui/widgets/profile_reposts_tab.dart index 2f25d776..a655a2f6 100644 --- a/lib/src/features/profile/ui/widgets/profile_reposts_tab.dart +++ b/lib/src/features/profile/ui/widgets/profile_reposts_tab.dart @@ -1,9 +1,9 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:auto_route/auto_route.dart'; -import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/features/profile/providers/profile_reposts_provider.dart'; import 'package:spark/src/features/profile/ui/widgets/profile_grid_widget.dart'; @@ -13,18 +13,22 @@ import 'package:spark/src/features/profile/ui/widgets/profile_tab_base.dart'; class ProfileRepostsTab extends ProfileTabBase { const ProfileRepostsTab({ required this.profileUri, + this.bsky = false, super.key, }); final AtUri profileUri; + /// Whether to use Bluesky API instead of Spark API. + final bool bsky; + @override List buildSlivers(BuildContext context, WidgetRef ref) { // Extract actor identifier from profileUri (DID or handle) final actor = profileUri.hostname; void onPostTap(BuildContext context, WidgetRef ref, AtUri postUri) { - ref.read(profileRepostsProvider(actor)).whenData((repostsState) { + ref.read(profileRepostsProvider(actor, bsky)).whenData((repostsState) { final filteredUris = repostsState.loadedPosts; final postIndex = filteredUris.indexOf(postUri); if (postIndex != -1) { @@ -32,6 +36,7 @@ class ProfileRepostsTab extends ProfileTabBase { StandaloneRepostsFeedRoute( did: actor, initialPostIndex: postIndex, + bsky: bsky, ), ); } else { @@ -64,7 +69,7 @@ class ProfileRepostsTab extends ProfileTabBase { required String actor, required Function(BuildContext, WidgetRef, AtUri) onPostTap, }) { - final repostsState = ref.watch(profileRepostsProvider(actor)); + final repostsState = ref.watch(profileRepostsProvider(actor, bsky)); return repostsState.when( data: (state) { @@ -78,8 +83,7 @@ class ProfileRepostsTab extends ProfileTabBase { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - FluentIcons.arrow_repeat_all_24_regular, + AppIcons.repost( size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -161,13 +165,13 @@ class ProfileRepostsTab extends ProfileTabBase { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(FluentIcons.error_circle_24_regular, size: 48), + const Icon(Icons.error_outline, size: 48), const SizedBox(height: 16), Text('Error loading reposts: $error'), const SizedBox(height: 16), ElevatedButton( onPressed: () => ref - .read(profileRepostsProvider(actor).notifier) + .read(profileRepostsProvider(actor, bsky).notifier) .refresh(), child: const Text('Retry'), ), diff --git a/lib/src/features/profile/ui/widgets/user_list_view.dart b/lib/src/features/profile/ui/widgets/user_list_view.dart index 0a64de2c..34fa847b 100644 --- a/lib/src/features/profile/ui/widgets/user_list_view.dart +++ b/lib/src/features/profile/ui/widgets/user_list_view.dart @@ -26,13 +26,23 @@ class UserListView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { if (users.isEmpty) { - return const Center( - child: Text('No users to display.'), + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + controller: scrollController, + children: const [ + Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('No users to display.'), + ), + ), + ], ); } return ListView.builder( controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), itemCount: users.length + (isFetchingMore ? 1 : 0), itemBuilder: (context, index) { if (isFetchingMore && index == users.length) { diff --git a/lib/src/features/settings/ui/pages/settings_page.dart b/lib/src/features/settings/ui/pages/settings_page.dart index fc00b6b1..12af1a9e 100644 --- a/lib/src/features/settings/ui/pages/settings_page.dart +++ b/lib/src/features/settings/ui/pages/settings_page.dart @@ -222,7 +222,8 @@ class _SettingsPageState extends ConsumerState { style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), trailing: const Icon(FluentIcons.tag_24_regular), - onTap: () => context.router.push(const LabelerManagementRoute()), + onTap: () => + context.router.push(const LabelerManagementRoute()), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, diff --git a/widgetbook/lib/templates/profile_page_template.dart b/widgetbook/lib/templates/profile_page_template.dart index 186a49c8..2e23f2ba 100644 --- a/widgetbook/lib/templates/profile_page_template.dart +++ b/widgetbook/lib/templates/profile_page_template.dart @@ -164,7 +164,6 @@ Widget buildProfilePageTemplateOtherUserNotFollowingUseCase( onFollowingTap: () => print('Following tapped'), onFollowTap: () => print('Follow tapped'), onUnfollowTap: () => print('Unfollow tapped'), - onShareTap: () => print('Share tapped'), onEarlySupporterTap: () => print('Early supporter badge tapped'), onMentionTap: (username) => print('Mention tapped: $username'), appBarActions: [ @@ -221,7 +220,6 @@ Widget buildProfilePageTemplateOtherUserFollowingUseCase(BuildContext context) { onFollowingTap: () => print('Following tapped'), onFollowTap: () => print('Follow tapped'), onUnfollowTap: () => print('Unfollow tapped'), - onShareTap: () => print('Share tapped'), onEarlySupporterTap: () => print('Early supporter badge tapped'), onMentionTap: (username) => print('Mention tapped: $username'), appBarActions: [