[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

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

feat(profile): polish up handle and display names

+477 -387
+12 -4
lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart
··· 28 28 final theme = Theme.of(context); 29 29 final iconColor = color ?? theme.textTheme.titleLarge?.color; 30 30 31 - return IconButton( 32 - onPressed: action, 33 - icon: AppIcons.chevronleft(color: iconColor), 34 - tooltip: tooltip ?? 'Back', 31 + return SizedBox( 32 + width: 40, 33 + height: 40, 34 + child: Tooltip( 35 + message: tooltip ?? 'Back', 36 + child: GestureDetector( 37 + onTap: action, 38 + child: Center( 39 + child: AppIcons.chevronleft(color: iconColor, size: 35), 40 + ), 41 + ), 42 + ), 35 43 ); 36 44 }, 37 45 );
+4 -1
lib/src/core/design_system/components/molecules/known_interactions_bar.dart
··· 104 104 children: [ 105 105 icon, 106 106 const SizedBox(width: 6), 107 - AvatarStack(avatars: avatars), 107 + AvatarStack( 108 + avatars: avatars, 109 + largeSize: 32, 110 + ), 108 111 ], 109 112 ), 110 113 ),
+46 -13
lib/src/core/design_system/components/molecules/profile_action_buttons.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:spark/src/core/design_system/components/atoms/buttons/text_button.dart' 3 - as ds; 2 + import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; 4 3 import 'package:spark/src/core/design_system/components/atoms/toggles/follow_button.dart'; 4 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 5 + import 'package:spark/src/core/design_system/tokens/typography.dart'; 5 6 6 7 class ProfileActionButtons extends StatelessWidget { 7 8 const ProfileActionButtons({ ··· 13 14 this.onFollowTap, 14 15 this.onUnfollowTap, 15 16 this.onUnblockTap, 16 - this.onShareTap, 17 17 }); 18 18 19 19 final bool isCurrentUser; ··· 23 23 final VoidCallback? onFollowTap; 24 24 final VoidCallback? onUnfollowTap; 25 25 final VoidCallback? onUnblockTap; 26 - final VoidCallback? onShareTap; 27 26 28 27 @override 29 28 Widget build(BuildContext context) { ··· 31 30 return Row( 32 31 children: [ 33 32 Expanded( 34 - child: ds.TextButton( 35 - label: 'Edit', 33 + child: _EditButton( 36 34 onTap: onEditTap, 37 35 ), 38 36 ), 39 - const SizedBox(width: 8), 40 - Expanded( 41 - child: ds.TextButton( 42 - label: 'Share Profile', 43 - onTap: onShareTap, 44 - ), 45 - ), 46 37 ], 47 38 ); 48 39 } ··· 65 56 ); 66 57 } 67 58 } 59 + 60 + /// Edit button that matches the FollowButton's following state style 61 + class _EditButton extends StatelessWidget { 62 + const _EditButton({ 63 + this.onTap, 64 + }); 65 + 66 + final VoidCallback? onTap; 67 + 68 + @override 69 + Widget build(BuildContext context) { 70 + final isDark = Theme.of(context).brightness == Brightness.dark; 71 + 72 + return InteractivePressable( 73 + onTap: onTap, 74 + borderRadius: const BorderRadius.all(Radius.circular(8)), 75 + child: Container( 76 + width: double.infinity, 77 + height: 36, 78 + decoration: BoxDecoration( 79 + color: isDark ? AppColors.darkGreyButton : AppColors.lightGreyButton, 80 + borderRadius: const BorderRadius.all(Radius.circular(8)), 81 + border: Border.fromBorderSide( 82 + BorderSide( 83 + color: isDark 84 + ? AppColors.grey700.withValues(alpha: 0.3) 85 + : AppColors.grey100.withValues(alpha: 0.3), 86 + width: 1.14667, 87 + ), 88 + ), 89 + ), 90 + child: const Align( 91 + child: Text( 92 + 'Edit', 93 + textAlign: TextAlign.center, 94 + style: AppTypography.textSmallMedium, 95 + ), 96 + ), 97 + ), 98 + ); 99 + } 100 + }
-6
lib/src/core/design_system/components/molecules/profile_avatar.dart
··· 63 63 ? AppColors.darkPurple 64 64 : AppColors.lightLavender, 65 65 shape: BoxShape.circle, 66 - border: Border.all( 67 - color: isDarkMode 68 - ? AppColors.darkPurple 69 - : AppColors.lightLavender, 70 - width: 2, 71 - ), 72 66 ), 73 67 child: hasStories 74 68 ? Container(
+25 -68
lib/src/core/design_system/templates/info_bar_template.dart
··· 71 71 widget.onDescriptionExpandToggle?.call(_isDescriptionExpanded); 72 72 } 73 73 74 - Widget _buildHandleText(BuildContext context, String handle) { 75 - const color = AppColors.greyWhite; 76 - 77 - final parts = handle.split('.'); 78 - if (parts.length == 1) { 79 - return GestureDetector( 80 - onTap: widget.onHandleTap, 81 - child: Text( 82 - '@$handle', 83 - style: AppTypography.textSmallThin.copyWith(color: color), 84 - ), 85 - ); 86 - } 87 - 88 - final first = parts.first; 89 - final rest = parts.sublist(1).join('.'); 90 - return GestureDetector( 91 - onTap: widget.onHandleTap, 92 - child: RichText( 93 - text: TextSpan( 94 - style: AppTypography.textSmallThin.copyWith(color: color), 95 - children: [ 96 - TextSpan(text: '@$first'), 97 - TextSpan( 98 - text: '.$rest', 99 - style: AppTypography.textSmallThin.copyWith( 100 - color: color.withAlpha(128), 101 - ), 102 - ), 103 - ], 104 - ), 105 - ), 106 - ); 107 - } 108 - 109 74 @override 110 75 Widget build(BuildContext context) { 111 76 const textColor = AppColors.greyWhite; ··· 117 82 crossAxisAlignment: CrossAxisAlignment.start, 118 83 children: [ 119 84 Row( 120 - crossAxisAlignment: CrossAxisAlignment.start, 121 85 children: [ 122 - Align( 123 - alignment: Alignment.bottomRight, 124 - child: Padding( 125 - padding: const EdgeInsets.all(8), 126 - child: ProfileAvatar( 127 - avatarUrl: widget.avatarUrl, 128 - displayName: widget.displayName, 129 - size: 36, 130 - onTap: widget.onAvatarTap ?? widget.onTitleTap, 131 - ), 132 - ), 133 - ), 86 + Expanded( 87 + child: GestureDetector( 88 + onTap: widget.onTitleTap, 89 + child: Row( 90 + children: [ 91 + Padding( 92 + padding: const EdgeInsets.symmetric(horizontal: 8), 93 + child: ProfileAvatar( 94 + avatarUrl: widget.avatarUrl, 95 + displayName: widget.displayName, 96 + size: 32, 97 + onTap: widget.onAvatarTap ?? widget.onTitleTap, 98 + ), 99 + ), 134 100 135 - Expanded( 136 - child: Column( 137 - crossAxisAlignment: CrossAxisAlignment.start, 138 - children: [ 139 - GestureDetector( 140 - onTap: widget.onTitleTap, 141 - child: Text( 142 - widget.displayName, 143 - style: AppTypography.textMediumBold.copyWith( 144 - color: textColor, 101 + Expanded( 102 + child: Text( 103 + widget.handle, 104 + style: AppTypography.textMediumBold.copyWith( 105 + color: textColor, 106 + fontSize: 17, 107 + ), 108 + overflow: TextOverflow.ellipsis, 145 109 ), 146 - overflow: TextOverflow.ellipsis, 147 110 ), 148 - ), 149 - const SizedBox(height: 2), 150 - Row( 151 - children: [ 152 - Expanded(child: _buildHandleText(context, widget.handle)), 153 - if (widget.altAvailable) _AltPill(onTap: widget.onAltTap), 154 - ], 155 - ), 156 - ], 111 + if (widget.altAvailable) _AltPill(onTap: widget.onAltTap), 112 + ], 113 + ), 157 114 ), 158 115 ), 159 116 if (widget.showFollowButton)
+4 -45
lib/src/core/design_system/templates/profile_page_template.dart
··· 7 7 import 'package:spark/src/core/design_system/components/molecules/profile_info.dart'; 8 8 import 'package:spark/src/core/design_system/components/molecules/profile_stats.dart'; 9 9 import 'package:spark/src/core/design_system/components/organisms/sticky_profile_tab_bar.dart'; 10 - import 'package:spark/src/core/design_system/tokens/colors.dart'; 11 10 import 'package:spark/src/core/design_system/tokens/typography.dart'; 12 11 13 12 class ProfilePageTemplate extends StatelessWidget { ··· 35 34 this.onFollowTap, 36 35 this.onUnfollowTap, 37 36 this.onUnblockTap, 38 - this.onShareTap, 39 37 this.onEarlySupporterTap, 40 38 this.onMentionTap, 41 39 this.onAddStoryTap, ··· 69 67 final VoidCallback? onFollowTap; 70 68 final VoidCallback? onUnfollowTap; 71 69 final VoidCallback? onUnblockTap; 72 - final VoidCallback? onShareTap; 73 70 final VoidCallback? onEarlySupporterTap; 74 71 final Function(String username)? onMentionTap; 75 72 final VoidCallback? onAddStoryTap; ··· 88 85 Widget build(BuildContext context) { 89 86 return Scaffold( 90 87 appBar: AppBar( 91 - centerTitle: true, 88 + centerTitle: isCurrentUser, 89 + leadingWidth: 40, 92 90 title: appBarTitle != null 93 91 ? Text( 94 92 appBarTitle!, ··· 107 105 }, 108 106 child: CustomScrollView( 109 107 controller: scrollController, 108 + physics: const AlwaysScrollableScrollPhysics(), 110 109 slivers: [ 111 110 SliverToBoxAdapter( 112 111 child: Skeletonizer( ··· 132 131 onFollowTap: onFollowTap, 133 132 onUnfollowTap: onUnfollowTap, 134 133 onUnblockTap: onUnblockTap, 135 - onShareTap: onShareTap, 136 134 onEarlySupporterTap: onEarlySupporterTap, 137 135 onMentionTap: onMentionTap, 138 136 onAddStoryTap: onAddStoryTap, ··· 177 175 this.onFollowTap, 178 176 this.onUnfollowTap, 179 177 this.onUnblockTap, 180 - this.onShareTap, 181 178 this.onEarlySupporterTap, 182 179 this.onMentionTap, 183 180 this.onAddStoryTap, ··· 203 200 final VoidCallback? onFollowTap; 204 201 final VoidCallback? onUnfollowTap; 205 202 final VoidCallback? onUnblockTap; 206 - final VoidCallback? onShareTap; 207 203 final VoidCallback? onEarlySupporterTap; 208 204 final Function(String username)? onMentionTap; 209 205 final VoidCallback? onAddStoryTap; 210 206 211 - Widget _buildHandleText(BuildContext context, String handle) { 212 - final theme = Theme.of(context); 213 - final textColor = theme.textTheme.bodyMedium?.color ?? AppColors.greyWhite; 214 - 215 - final parts = handle.split('.'); 216 - if (parts.length == 1) { 217 - return Text( 218 - '@$handle', 219 - style: AppTypography.textSmallThin.copyWith(color: textColor), 220 - ); 221 - } 222 - 223 - final firstPart = parts[0]; 224 - final remainingPart = parts.sublist(1).join('.'); 225 - 226 - return RichText( 227 - text: TextSpan( 228 - style: AppTypography.textSmallThin.copyWith( 229 - color: textColor, 230 - ), 231 - children: [ 232 - TextSpan(text: '@$firstPart'), 233 - TextSpan( 234 - text: '.$remainingPart', 235 - style: AppTypography.textSmallThin.copyWith( 236 - color: textColor.withAlpha(128), 237 - ), 238 - ), 239 - ], 240 - ), 241 - ); 242 - } 243 - 244 207 @override 245 208 Widget build(BuildContext context) { 246 209 return Padding( ··· 256 219 avatarUrl: avatarUrl, 257 220 displayName: displayName, 258 221 hasStories: hasStories, 222 + size: 80, 259 223 onTap: onAvatarTap, 260 224 showAddButton: isCurrentUser, 261 225 onAddTap: onAddStoryTap, ··· 284 248 ), 285 249 ), 286 250 ], 287 - ), 288 - const SizedBox(height: 4), 289 - Skeleton.keep( 290 - child: _buildHandleText(context, handle), 291 251 ), 292 252 const SizedBox(height: 10), 293 253 ProfileStats( ··· 323 283 onFollowTap: onFollowTap, 324 284 onUnfollowTap: onUnfollowTap, 325 285 onUnblockTap: onUnblockTap, 326 - onShareTap: onShareTap, 327 286 ), 328 287 ), 329 288 ],
+7 -2
lib/src/core/network/atproto/data/repositories/actor_repository.dart
··· 6 6 /// Get a profile by DID 7 7 /// 8 8 /// [did] The DID of the profile to get 9 - Future<ProfileViewDetailed> getProfile(String did); 9 + /// [useBluesky] Whether to use Bluesky API instead of Spark (default false) 10 + Future<ProfileViewDetailed> getProfile(String did, {bool useBluesky = false}); 10 11 11 12 /// Get multiple profiles by their DIDs 12 13 /// 13 14 /// [dids] A list of DIDs to fetch profiles for 14 - Future<List<ProfileViewDetailed>> getProfiles(List<String> dids); 15 + /// [useBluesky] Whether to use Bluesky API instead of Spark (default false) 16 + Future<List<ProfileViewDetailed>> getProfiles( 17 + List<String> dids, { 18 + bool useBluesky = false, 19 + }); 15 20 16 21 /// Search actors by query string. 17 22 ///
+53 -47
lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart
··· 22 22 ); 23 23 24 24 @override 25 - Future<ProfileViewDetailed> getProfile(String did) async { 26 - _logger.d('Getting profile for DID: $did'); 25 + Future<ProfileViewDetailed> getProfile( 26 + String did, { 27 + bool useBluesky = false, 28 + }) async { 29 + _logger.d('Getting profile for DID: $did, useBluesky: $useBluesky'); 27 30 return _client.executeWithRetry(() async { 28 31 if (!_client.authRepository.isAuthenticated) { 29 32 _logger.w('Not authenticated'); ··· 35 38 _logger.e('AtProto not initialized'); 36 39 throw Exception('AtProto not initialized'); 37 40 } 38 - try { 39 - final result = await atproto.get( 40 - NSID.parse('so.sprk.actor.getProfile'), 41 - parameters: {'actor': did}, 42 - headers: {'atproto-proxy': _client.sprkDid}, 43 - to: (jsonMap) => jsonMap, 44 - adaptor: (uint8) => 45 - jsonDecode(utf8.decode(uint8 as List<int>)) 46 - as Map<String, dynamic>, 47 - ); 48 - return ProfileViewDetailed.fromJson( 49 - result.data as Map<String, dynamic>, 50 - ); 51 - } catch (e) { 52 - _logger 53 - ..e('Failed to retrieve profile for DID: $did', error: e) 54 - ..i('Trying to get profile from bluesky'); 41 + 42 + // Use Bluesky API if explicitly requested 43 + if (useBluesky) { 55 44 final oauthSession = atproto.oAuthSession; 56 45 if (oauthSession == null) { 57 46 throw Exception('No OAuth session available'); 58 47 } 59 - final bluesky = bsky.Bluesky.fromOAuthSession(oauthSession); 48 + final blueskyClient = bsky.Bluesky.fromOAuthSession(oauthSession); 60 49 final profile = await bskyActorAdapter.getProfileFromBluesky( 61 - bluesky, 50 + blueskyClient, 62 51 did, 63 52 ); 64 - _logger.d('Profile retrieved successfully from bluesky'); 53 + _logger.d('Profile retrieved successfully from Bluesky'); 65 54 return profile; 66 55 } 56 + 57 + // Use Spark API (no fallback) 58 + final result = await atproto.get( 59 + NSID.parse('so.sprk.actor.getProfile'), 60 + parameters: {'actor': did}, 61 + headers: {'atproto-proxy': _client.sprkDid}, 62 + to: (jsonMap) => jsonMap, 63 + adaptor: (uint8) => 64 + jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 65 + ); 66 + _logger.d('Profile retrieved successfully from Spark'); 67 + return ProfileViewDetailed.fromJson( 68 + result.data as Map<String, dynamic>, 69 + ); 67 70 }); 68 71 } 69 72 ··· 176 179 } 177 180 178 181 @override 179 - Future<List<ProfileViewDetailed>> getProfiles(List<String> dids) { 182 + Future<List<ProfileViewDetailed>> getProfiles( 183 + List<String> dids, { 184 + bool useBluesky = false, 185 + }) { 180 186 return _client.executeWithRetry(() async { 181 - _logger.d('Getting profiles for DIDs: $dids'); 187 + _logger.d('Getting profiles for DIDs: $dids, useBluesky: $useBluesky'); 182 188 if (dids.isEmpty) { 183 189 _logger.w('No DIDs provided, returning empty list'); 184 190 return <ProfileViewDetailed>[]; ··· 193 199 _logger.e('AtProto not initialized'); 194 200 throw Exception('AtProto not initialized'); 195 201 } 196 - try { 197 - final result = await atproto.get( 198 - NSID.parse('so.sprk.actor.getProfiles'), 199 - parameters: {'actors': dids}, 200 - headers: {'atproto-proxy': _client.sprkDid}, 201 - to: (jsonMap) => jsonMap, 202 - adaptor: (uint8) => 203 - jsonDecode(utf8.decode(uint8 as List<int>)) 204 - as Map<String, dynamic>, 205 - ); 206 - return (result.data['profiles']! as List) 207 - .map( 208 - (json) => 209 - ProfileViewDetailed.fromJson(json as Map<String, dynamic>), 210 - ) 211 - .toList(); 212 - } catch (e) { 213 - _logger 214 - ..e('Failed to retrieve profile for DIDs: $dids', error: e) 215 - ..i('Trying to get profiles from bluesky'); 202 + 203 + // Use Bluesky API if explicitly requested 204 + if (useBluesky) { 216 205 final oauthSession = atproto.oAuthSession; 217 206 if (oauthSession == null) { 218 207 throw Exception('No OAuth session available'); 219 208 } 220 - final bluesky = bsky.Bluesky.fromOAuthSession(oauthSession); 209 + final blueskyClient = bsky.Bluesky.fromOAuthSession(oauthSession); 221 210 final profiles = await bskyActorAdapter.getProfilesFromBluesky( 222 - bluesky, 211 + blueskyClient, 223 212 dids, 224 213 ); 225 - _logger.d('Profiles retrieved successfully from bluesky'); 214 + _logger.d('Profiles retrieved successfully from Bluesky'); 226 215 return profiles; 227 216 } 217 + 218 + // Use Spark API (no fallback) 219 + final result = await atproto.get( 220 + NSID.parse('so.sprk.actor.getProfiles'), 221 + parameters: {'actors': dids}, 222 + headers: {'atproto-proxy': _client.sprkDid}, 223 + to: (jsonMap) => jsonMap, 224 + adaptor: (uint8) => 225 + jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 226 + ); 227 + _logger.d('Profiles retrieved successfully from Spark'); 228 + return (result.data['profiles']! as List) 229 + .map( 230 + (json) => 231 + ProfileViewDetailed.fromJson(json as Map<String, dynamic>), 232 + ) 233 + .toList(); 228 234 }); 229 235 } 230 236 }
+2
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 222 222 /// [actor] The at-identifier of the actor (handle or DID) 223 223 /// [limit] The number of items to return (default 50, max 100) 224 224 /// [cursor] Pagination cursor for the next set of results 225 + /// [bluesky] Whether to fetch from Bluesky API instead of Spark 225 226 Future<({List<FeedViewPost> posts, String? cursor})> getActorReposts( 226 227 String actor, { 227 228 int limit = 50, 228 229 String? cursor, 230 + bool bluesky = false, 229 231 }); 230 232 }
+22 -1
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 1527 1527 String actor, { 1528 1528 int limit = 50, 1529 1529 String? cursor, 1530 + bool bluesky = false, 1530 1531 }) async { 1531 1532 _logger.d( 1532 - 'Getting actor reposts for actor: $actor, limit: $limit, cursor: $cursor', 1533 + 'Getting actor reposts for actor: $actor, limit: $limit, ' 1534 + 'cursor: $cursor, bluesky: $bluesky', 1533 1535 ); 1536 + 1537 + if (bluesky) { 1538 + return _getActorRepostsFromBluesky(actor, limit: limit, cursor: cursor); 1539 + } 1534 1540 1535 1541 return _client.executeWithRetry(() async { 1536 1542 if (!_client.authRepository.isAuthenticated) { ··· 1577 1583 ); 1578 1584 return result.data; 1579 1585 }); 1586 + } 1587 + 1588 + /// Get actor reposts from Bluesky API 1589 + /// Note: Bluesky doesn't have a direct getActorReposts endpoint, 1590 + /// so we return an empty result in Bluesky mode. 1591 + Future<({List<FeedViewPost> posts, String? cursor})> 1592 + _getActorRepostsFromBluesky( 1593 + String actor, { 1594 + required int limit, 1595 + required String? cursor, 1596 + }) async { 1597 + _logger.w( 1598 + 'getActorReposts is not available for Bluesky API, returning empty', 1599 + ); 1600 + return (posts: <FeedViewPost>[], cursor: null); 1580 1601 } 1581 1602 1582 1603 /// Helper method to determine content type based on file extension
+8 -2
lib/src/core/network/atproto/data/repositories/graph_repository.dart
··· 18 18 /// Follow a user 19 19 /// 20 20 /// [did] The DID of the user to follow 21 - Future<FollowUserResponse> followUser(String did); 21 + /// [bsky] Whether to use Bluesky follow records instead of Spark 22 + Future<FollowUserResponse> followUser(String did, {bool bsky = false}); 22 23 23 24 /// Unfollow a user 24 25 /// ··· 29 30 /// 30 31 /// [did] The DID of the user to toggle follow for 31 32 /// [currentFollowUri] The follow URI if following, null if not 33 + /// [bsky] Whether to use Bluesky follow records instead of Spark 32 34 /// Returns the follow URI if now following, null if unfollowed 33 - Future<String?> toggleFollow(String did, AtUri? currentFollowUri); 35 + Future<String?> toggleFollow( 36 + String did, 37 + AtUri? currentFollowUri, { 38 + bool bsky = false, 39 + }); 34 40 35 41 /// Get blocks for a DID 36 42 ///
+15 -6
lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart
··· 93 93 } 94 94 95 95 @override 96 - Future<FollowUserResponse> followUser(String did) async { 97 - _logger.d('Following user with DID: $did'); 96 + Future<FollowUserResponse> followUser(String did, {bool bsky = false}) async { 97 + _logger.d('Following user with DID: $did, bsky: $bsky'); 98 98 return _client.executeWithRetry(() async { 99 99 if (!_client.authRepository.isAuthenticated) { 100 100 _logger.w('Not authenticated'); ··· 113 113 throw Exception('Session DID not available'); 114 114 } 115 115 116 - const collection = 'so.sprk.graph.follow'; 116 + final collection = bsky 117 + ? 'app.bsky.graph.follow' 118 + : 'so.sprk.graph.follow'; 117 119 118 120 try { 119 121 _logger.d('Checking if already following user: $did'); ··· 164 166 } 165 167 166 168 @override 167 - Future<String?> toggleFollow(String did, AtUri? currentFollowUri) async { 168 - _logger.d('Toggling follow for DID: $did, current URI: $currentFollowUri'); 169 + Future<String?> toggleFollow( 170 + String did, 171 + AtUri? currentFollowUri, { 172 + bool bsky = false, 173 + }) async { 174 + _logger.d( 175 + 'Toggling follow for DID: $did, current URI: $currentFollowUri, ' 176 + 'bsky: $bsky', 177 + ); 169 178 return _client.executeWithRetry(() async { 170 179 if (currentFollowUri != null) { 171 180 // User is following, so unfollow ··· 174 183 return null; 175 184 } else { 176 185 // User is not following, so follow 177 - final response = await followUser(did); 186 + final response = await followUser(did, bsky: bsky); 178 187 _logger.i('User followed via toggle: ${response.uri}'); 179 188 return response.uri; 180 189 }
+4
lib/src/features/comments/ui/widgets/comment_item.dart
··· 46 46 47 47 void _navigateToProfile() { 48 48 final author = commentState.thread.post.author; 49 + final isBskyPost = widget.mainPostUri.collection.toString().startsWith( 50 + 'app.bsky', 51 + ); 49 52 context.router.push( 50 53 ProfileRoute( 51 54 did: author.did, 52 55 initialProfile: author, 56 + bsky: isBskyPost, 53 57 ), 54 58 ); 55 59 }
+6
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 183 183 onUsernameTap: () { 184 184 // Pause video before navigating to profile 185 185 _videoPlayerKey.currentState?.pauseVideo(); 186 + final isBskyPost = postData.uri.collection 187 + .toString() 188 + .startsWith( 189 + 'app.bsky', 190 + ); 186 191 context.router.push( 187 192 ProfileRoute( 188 193 did: postData.author.did, 189 194 initialProfile: postData.author, 195 + bsky: isBskyPost, 190 196 ), 191 197 ); 192 198 },
+2 -2
lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart
··· 370 370 } else { 371 371 final profileUri = AtUri.parse('at://${currentPost.author.did}'); 372 372 ref 373 - ..invalidate(profileFeedProvider(profileUri, false)) 374 - ..invalidate(profileFeedProvider(profileUri, true)); 373 + ..invalidate(profileFeedProvider(profileUri, false, false)) 374 + ..invalidate(profileFeedProvider(profileUri, true, false)); 375 375 } 376 376 } catch (e) { 377 377 // Error handling - snackbar removed
+6
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 309 309 }, 310 310 onUsernameTap: () { 311 311 _videoPlayerKey.currentState?.pauseVideo(); 312 + final isBskyPost = currentPost.uri.collection 313 + .toString() 314 + .startsWith( 315 + 'app.bsky', 316 + ); 312 317 context.router.push( 313 318 ProfileRoute( 314 319 did: currentPost.author.did, 315 320 initialProfile: currentPost.author, 321 + bsky: isBskyPost, 316 322 ), 317 323 ); 318 324 },
+10 -2
lib/src/features/posting/ui/pages/image_review_page.dart
··· 197 197 if (did != null) { 198 198 ref 199 199 ..invalidate( 200 - profileFeedProvider(AtUri.parse('at://$did'), false), 200 + profileFeedProvider( 201 + AtUri.parse('at://$did'), 202 + false, 203 + false, 204 + ), 201 205 ) 202 206 ..invalidate( 203 - profileFeedProvider(AtUri.parse('at://$did'), true), 207 + profileFeedProvider( 208 + AtUri.parse('at://$did'), 209 + true, 210 + false, 211 + ), 204 212 ); 205 213 } 206 214 if (!widget.storyMode) {
+6 -2
lib/src/features/posting/ui/pages/video_review_page.dart
··· 111 111 final did = ref.read(currentDidProvider); 112 112 if (did != null) { 113 113 ref 114 - ..invalidate(profileFeedProvider(AtUri.parse('at://$did'), false)) 115 - ..invalidate(profileFeedProvider(AtUri.parse('at://$did'), true)); 114 + ..invalidate( 115 + profileFeedProvider(AtUri.parse('at://$did'), false, false), 116 + ) 117 + ..invalidate( 118 + profileFeedProvider(AtUri.parse('at://$did'), true, false), 119 + ); 116 120 } 117 121 if (postRef == null) { 118 122 return;
+9 -6
lib/src/features/profile/providers/profile_feed_provider.dart
··· 21 21 bool _isLoading = false; 22 22 23 23 @override 24 - Future<ProfileFeedState> build(AtUri profileUri, bool videosOnly) async { 24 + Future<ProfileFeedState> build( 25 + AtUri profileUri, 26 + bool videosOnly, 27 + bool bsky, 28 + ) async { 25 29 try { 26 30 final result = await _loadUnifiedFeed( 27 31 profileUri: profileUri, ··· 40 44 } 41 45 } 42 46 43 - /// Load author feed from Spark first, falling back to Bluesky if Spark fails. 44 - /// This mirrors the profile loading behavior where we only show one source. 47 + /// Load author feed from API (Spark by default, Bluesky if bsky=true). 45 48 Future<ProfileFeedState> _loadUnifiedFeed({ 46 49 required AtUri profileUri, 47 50 required String? sparkCursor, ··· 58 61 59 62 final newPosts = <PostView>[]; 60 63 61 - // Fetch from Spark API (internally falls back to Bluesky if Spark fails) 62 - // This mirrors profile loading behavior 64 + // Fetch from the specified API (Spark by default, Bluesky if bsky=true) 63 65 final result = await _fetchFromSource( 64 66 (cursor) => _feedRepository.getAuthorFeed( 65 67 profileUri, 66 68 limit: ProfileFeedState.fetchLimit, 67 69 cursor: cursor, 70 + bluesky: bsky, 68 71 ), 69 72 sparkCursor, 70 - 'AuthorFeed', 73 + bsky ? 'BlueskyAuthorFeed' : 'SparkAuthorFeed', 71 74 ); 72 75 73 76 for (final feedViewPost in result.posts) {
+25 -60
lib/src/features/profile/providers/profile_provider.dart
··· 29 29 late final SparkLogger logger; 30 30 31 31 @override 32 - Future<ProfileState> build({String? did}) async { 33 - logger.d('Building ProfileNotifier for did: $did'); 32 + Future<ProfileState> build({String? did, bool bsky = false}) async { 33 + logger.d('Building ProfileNotifier for did: $did, bsky: $bsky'); 34 34 final initialState = ProfileState(currentViewDid: did); 35 35 await loadProfileData(did, initialState); 36 36 // Return the state value if available, otherwise return initial state ··· 59 59 } 60 60 61 61 try { 62 - logger.d('Loading profile for DID: $effectiveDid'); 63 - final profile = await actorRepository.getProfile(effectiveDid); 62 + logger.d('Loading profile for DID: $effectiveDid, bsky: $bsky'); 63 + final profile = await actorRepository.getProfile( 64 + effectiveDid, 65 + useBluesky: bsky, 66 + ); 64 67 65 68 logger.d( 66 69 'Profile loaded successfully for $effectiveDid: ${profile.handle}', ··· 86 89 Future<void> refreshProfile() async { 87 90 final currentProfileState = state.asData?.value; 88 91 final currentDid = currentProfileState?.currentViewDid; 89 - logger.d('Refreshing profile for DID: $currentDid'); 92 + logger.d('Refreshing profile for DID: $currentDid, bsky: $bsky'); 90 93 91 94 final didToRefresh = currentDid ?? authRepository.did; 92 95 if (didToRefresh == null) { ··· 100 103 // Invalidate feed providers to force a rebuild rather than calling 101 104 // notifier methods directly, which can fail if providers are disposed 102 105 ref 103 - ..invalidate(profileFeedProvider(profileUri, true)) 104 - ..invalidate(profileFeedProvider(profileUri, false)); 106 + ..invalidate(profileFeedProvider(profileUri, true, bsky)) 107 + ..invalidate(profileFeedProvider(profileUri, false, bsky)); 105 108 106 109 // Load profile data 107 110 await loadProfileData( ··· 162 165 final newFollowUriResult = await sprkRepository.graph.toggleFollow( 163 166 profile.did, 164 167 profile.viewer?.following, 168 + bsky: bsky, 165 169 ); 166 170 167 171 if (newFollowUriResult != null) { ··· 173 177 logger.i('Successfully unfollowed ${profile.did}.'); 174 178 } 175 179 176 - // Update state optimistically first 180 + // Optimistically update the UI: follow state and follower count 181 + final currentFollowersCount = profile.followersCount ?? 0; 182 + 183 + // Update follower count: increment on follow, decrement on unfollow 184 + final newFollowersCount = newFollowUriResult != null 185 + ? currentFollowersCount + 1 186 + : (currentFollowersCount > 0 ? currentFollowersCount - 1 : 0); 187 + 177 188 final optimisticViewer = 178 189 profile.viewer?.copyWith( 179 190 following: newFollowUriResult != null ··· 186 197 : null, 187 198 ); 188 199 189 - final optimisticProfile = profile.copyWith(viewer: optimisticViewer); 200 + final optimisticProfile = profile.copyWith( 201 + viewer: optimisticViewer, 202 + followersCount: newFollowersCount, 203 + ); 204 + 190 205 state = AsyncData( 191 206 originalStateValue.copyWith(profile: optimisticProfile), 192 207 ); 193 208 194 - // Then refresh the profile data in the background to ensure consistency 195 - // Use a small delay to allow backend to propagate changes 196 - // Note: This is intentionally unawaited - we use optimistic updates above 197 - // & refresh in the background. If this fails, optimistic state remains. 198 - unawaited( 199 - Future.delayed(const Duration(milliseconds: 500)).then((_) async { 200 - try { 201 - final refreshedProfile = await actorRepository.getProfile( 202 - profile.did, 203 - ); 204 - 205 - // Only update if state hasn't changed (user hasn't navigated away) 206 - final currentState = state.asData?.value; 207 - if (currentState?.profile?.did == profile.did) { 208 - state = AsyncData( 209 - currentState!.copyWith(profile: refreshedProfile), 210 - ); 211 - } 212 - } catch (e) { 213 - logger.w('Background profile refresh failed: $e'); 214 - // Keep the optimistic state if refresh fails 215 - } 216 - }), 217 - ); 218 - 219 209 return newFollowUriResult; 220 210 } catch (e, s) { 221 211 logger.e( ··· 263 253 logger.i('Successfully unblocked ${profile.did}.'); 264 254 } 265 255 266 - // Update state optimistically first 256 + // Optimistically update the UI: block state 267 257 final optimisticViewer = 268 258 profile.viewer?.copyWith( 269 259 blocking: newBlockUriResult != null ··· 279 269 final optimisticProfile = profile.copyWith(viewer: optimisticViewer); 280 270 state = AsyncData( 281 271 originalStateValue.copyWith(profile: optimisticProfile), 282 - ); 283 - 284 - // Then refresh the profile data in the background to ensure consistency 285 - // Use a small delay to allow backend to propagate changes 286 - // Note: This is intentionally unawaited - we use optimistic updates above 287 - // & refresh in the background. If this fails, optimistic state remains. 288 - unawaited( 289 - Future.delayed(const Duration(milliseconds: 500)).then((_) async { 290 - try { 291 - final refreshedProfile = await actorRepository.getProfile( 292 - profile.did, 293 - ); 294 - 295 - // Only update if state hasn't changed (user hasn't navigated away) 296 - final currentState = state.asData?.value; 297 - if (currentState?.profile?.did == profile.did) { 298 - state = AsyncData( 299 - currentState!.copyWith(profile: refreshedProfile), 300 - ); 301 - } 302 - } catch (e) { 303 - logger.w('Background profile refresh failed: $e'); 304 - // Keep the optimistic state if refresh fails 305 - } 306 - }), 307 272 ); 308 273 309 274 return newBlockUriResult;
+5 -4
lib/src/features/profile/providers/profile_reposts_provider.dart
··· 22 22 late final String _actor; 23 23 24 24 @override 25 - Future<ProfileFeedState> build(String actor) async { 25 + Future<ProfileFeedState> build(String actor, bool bsky) async { 26 26 _actor = actor; 27 27 try { 28 28 final result = await _loadReposts( ··· 40 40 } 41 41 } 42 42 43 - /// Load reposts from Spark API 43 + /// Load reposts from specified API (Spark by default, Bluesky if bsky=true) 44 44 Future<ProfileFeedState> _loadReposts({ 45 45 required String actor, 46 46 required String? cursor, ··· 55 55 56 56 final newPosts = <PostView>[]; 57 57 58 - // Fetch from Spark API 58 + // Fetch from the specified API (Spark by default, Bluesky if bsky=true) 59 59 final result = await _fetchFromSource( 60 60 (cursor) => _feedRepository.getActorReposts( 61 61 actor, 62 62 limit: ProfileFeedState.fetchLimit, 63 63 cursor: cursor, 64 + bluesky: bsky, 64 65 ), 65 66 cursor, 66 - 'ActorReposts', 67 + bsky ? 'BlueskyActorReposts' : 'SparkActorReposts', 67 68 ); 68 69 69 70 for (final feedViewPost in result.posts) {
+128 -97
lib/src/features/profile/ui/pages/profile_page.dart
··· 34 34 const ProfilePage({ 35 35 @PathParam('did') required this.did, 36 36 this.initialProfile, 37 + this.bsky = false, 37 38 super.key, 38 39 }); 39 40 final String did; ··· 41 42 /// Optional initial profile data to show while loading. 42 43 // Can be partially filled - only did & handle required in ProfileViewBasic. 43 44 final actor_models.ProfileViewBasic? initialProfile; 45 + 46 + /// Whether to use Bluesky API instead of Spark API. 47 + /// Defaults to false (Spark API). 48 + final bool bsky; 44 49 45 50 @override 46 51 ConsumerState<ProfilePage> createState() => _ProfilePageState(); ··· 77 82 _scrollController.position.maxScrollExtent - 500) { 78 83 final profileUri = AtUri.parse('at://${widget.did}'); 79 84 if (_activeTabIndex == 0) { 80 - ref.read(profileFeedProvider(profileUri, false).notifier).loadMore(); 85 + ref 86 + .read(profileFeedProvider(profileUri, false, widget.bsky).notifier) 87 + .loadMore(); 81 88 } else if (_activeTabIndex == 1) { 82 89 final actor = profileUri.hostname; 83 - ref.read(profileRepostsProvider(actor).notifier).loadMore(); 90 + ref 91 + .read(profileRepostsProvider(actor, widget.bsky).notifier) 92 + .loadMore(); 84 93 } 85 94 } 86 95 } ··· 98 107 switch (tabIndex) { 99 108 case 0: 100 109 // First tab - default profile grid content (not a route) 101 - tabWidget = ProfileGridTab(profileUri: profileUri); 110 + tabWidget = ProfileGridTab(profileUri: profileUri, bsky: widget.bsky); 102 111 case 1: 103 112 // Second tab - reposts 104 - tabWidget = ProfileRepostsTab(profileUri: profileUri); 113 + tabWidget = ProfileRepostsTab( 114 + profileUri: profileUri, 115 + bsky: widget.bsky, 116 + ); 105 117 default: 106 118 // Fallback to first tab 107 - tabWidget = ProfileGridTab(profileUri: profileUri); 119 + tabWidget = ProfileGridTab(profileUri: profileUri, bsky: widget.bsky); 108 120 } 109 121 110 122 // Cache the tab widget to keep it loaded ··· 159 171 160 172 @override 161 173 Widget build(BuildContext context) { 162 - final profileStateAsync = ref.watch(profileProvider(did: widget.did)); 163 - final notifier = ref.read(profileProvider(did: widget.did).notifier); 174 + final profileStateAsync = ref.watch( 175 + profileProvider(did: widget.did, bsky: widget.bsky), 176 + ); 177 + final notifier = ref.read( 178 + profileProvider(did: widget.did, bsky: widget.bsky).notifier, 179 + ); 164 180 final theme = Theme.of(context); 165 181 final colorScheme = theme.colorScheme; 166 182 ··· 173 189 // Only watch the active tab's provider - lazy load other tabs 174 190 // This reduces initial load time by not fetching data for hidden tabs 175 191 if (_activeTabIndex == 0) { 176 - ref.watch(profileFeedProvider(profileUri, false)); 192 + ref.watch(profileFeedProvider(profileUri, false, widget.bsky)); 177 193 } else if (_activeTabIndex == 1) { 178 194 final actor = profileUri.hostname; 179 - ref.watch(profileRepostsProvider(actor)); 195 + ref.watch(profileRepostsProvider(actor, widget.bsky)); 180 196 } 181 197 182 198 // Build slivers for the active tab using cached widget ··· 211 227 appBarActions: isCurrentUser 212 228 ? [ 213 229 Padding( 214 - padding: const EdgeInsets.only(right: 8), 230 + padding: const EdgeInsets.only(right: 4), 215 231 child: IconButton( 216 232 padding: EdgeInsets.zero, 233 + constraints: const BoxConstraints(), 234 + splashColor: Colors.transparent, 235 + highlightColor: Colors.transparent, 217 236 onPressed: () => 218 237 context.router.push(const SettingsRoute()), 219 - icon: AppIcons.gear(color: colorScheme.onSurface), 238 + icon: AppIcons.gear( 239 + color: colorScheme.onSurface, 240 + size: 28, 241 + ), 220 242 ), 221 243 ), 222 244 ] ··· 280 302 _logger.e('Error unblocking profile', error: e); 281 303 } 282 304 }, 283 - onShareTap: () => 284 - _logger.i('Share profile tapped for ${profile.did}'), 285 305 onMentionTap: _handleUsernameTap, 286 306 onAddStoryTap: isCurrentUser ? () => _handleAddStory(context) : null, 287 - appBarTitle: profile.displayName ?? profile.handle, 307 + appBarTitle: profile.handle, 288 308 appBarActions: [ 289 309 if (isCurrentUser) 290 - Padding( 291 - padding: const EdgeInsets.only(right: 8), 292 - child: IconButton( 293 - padding: EdgeInsets.zero, 294 - onPressed: () => context.router.push(const SettingsRoute()), 295 - icon: AppIcons.gear(color: colorScheme.onSurface), 296 - ), 310 + IconButton( 311 + padding: EdgeInsets.zero, 312 + constraints: const BoxConstraints(), 313 + splashColor: Colors.transparent, 314 + highlightColor: Colors.transparent, 315 + onPressed: () => context.router.push(const SettingsRoute()), 316 + icon: AppIcons.gear(color: colorScheme.onSurface, size: 28), 297 317 ) 298 318 else 299 - Padding( 300 - padding: const EdgeInsets.only(right: 8), 301 - child: GestureDetector( 302 - onTap: () => OptionsPanel.show( 319 + IconButton( 320 + onPressed: () => OptionsPanel.show( 321 + context: context, 322 + onReport: () => showDialog( 303 323 context: context, 304 - onReport: () => showDialog( 305 - context: context, 306 - useRootNavigator: false, 307 - builder: (dContext) => ReportDialog( 308 - postUri: 309 - 'at://${profile.did}/app.bsky.actor.profile/self', 310 - postCid: profile.did, 311 - onSubmit: (subject, reasonType, reason) async { 312 - try { 313 - await notifier.createReport( 314 - did: profile.did, 315 - reasonType: reasonType, 316 - reason: reason, 317 - ); 318 - } catch (e) { 319 - _logger.e('Error creating report', error: e); 320 - } 321 - }, 322 - ), 324 + useRootNavigator: false, 325 + builder: (dContext) => ReportDialog( 326 + postUri: 327 + 'at://${profile.did}/app.bsky.actor.profile/self', 328 + postCid: profile.did, 329 + onSubmit: (subject, reasonType, reason) async { 330 + try { 331 + await notifier.createReport( 332 + did: profile.did, 333 + reasonType: reasonType, 334 + reason: reason, 335 + ); 336 + } catch (e) { 337 + _logger.e('Error creating report', error: e); 338 + } 339 + }, 323 340 ), 324 - onBlock: () async { 325 - final wasBlocked = isBlocking(profile.viewer); 341 + ), 342 + onBlock: () async { 343 + final wasBlocked = isBlocking(profile.viewer); 326 344 327 - // Show confirmation dialog 328 - final confirmed = await showDialog<bool>( 329 - context: context, 330 - builder: (context) => AlertDialog( 331 - title: Text( 332 - wasBlocked ? 'Unblock User' : 'Block User', 333 - ), 334 - content: Text( 335 - wasBlocked 336 - ? 'Are you sure you want to unblock this user?' 337 - : 'Are you sure you want to block this user? ' 338 - 'You will no longer see their posts.', 345 + // Show confirmation dialog 346 + final confirmed = await showDialog<bool>( 347 + context: context, 348 + builder: (context) => AlertDialog( 349 + title: Text( 350 + wasBlocked ? 'Unblock User' : 'Block User', 351 + ), 352 + content: Text( 353 + wasBlocked 354 + ? 'Are you sure you want to unblock this user?' 355 + : 'Are you sure you want to block this user? ' 356 + 'You will no longer see their posts.', 357 + ), 358 + actions: [ 359 + TextButton( 360 + onPressed: () => Navigator.of(context).pop(false), 361 + child: const Text('Cancel'), 339 362 ), 340 - actions: [ 341 - TextButton( 342 - onPressed: () => Navigator.of(context).pop(false), 343 - child: const Text('Cancel'), 344 - ), 345 - TextButton( 346 - onPressed: () => Navigator.of(context).pop(true), 347 - style: TextButton.styleFrom( 348 - foregroundColor: wasBlocked ? null : Colors.red, 349 - ), 350 - child: Text(wasBlocked ? 'Unblock' : 'Block'), 363 + TextButton( 364 + onPressed: () => Navigator.of(context).pop(true), 365 + style: TextButton.styleFrom( 366 + foregroundColor: wasBlocked ? null : Colors.red, 351 367 ), 352 - ], 353 - ), 354 - ); 368 + child: Text(wasBlocked ? 'Unblock' : 'Block'), 369 + ), 370 + ], 371 + ), 372 + ); 355 373 356 - if (confirmed != true) return; 374 + if (confirmed != true) return; 357 375 358 - try { 359 - await notifier.toggleBlock(); 360 - } catch (e) { 361 - _logger.e( 362 - 'Error blocking/unblocking profile', 363 - error: e, 364 - ); 365 - } 366 - }, 367 - isBlocked: isBlocking(profile.viewer), 368 - isProfile: true, 369 - ), 370 - child: Container( 371 - padding: const EdgeInsets.all(8), 372 - child: AppIcons.moreHoriz(color: colorScheme.onSurface), 373 - ), 376 + try { 377 + await notifier.toggleBlock(); 378 + } catch (e) { 379 + _logger.e( 380 + 'Error blocking/unblocking profile', 381 + error: e, 382 + ); 383 + } 384 + }, 385 + isBlocked: isBlocking(profile.viewer), 386 + isProfile: true, 387 + ), 388 + padding: EdgeInsets.zero, 389 + constraints: const BoxConstraints(), 390 + splashColor: Colors.transparent, 391 + highlightColor: Colors.transparent, 392 + icon: AppIcons.moreHoriz( 393 + color: colorScheme.onSurface, 394 + size: 35, 374 395 ), 375 396 ), 376 397 ], ··· 405 426 followersCount: '0', 406 427 followingCount: '0', 407 428 isCurrentUser: false, 408 - appBarTitle: initial?.displayName ?? initial?.handle, 429 + appBarTitle: initial?.handle ?? 'loading', 409 430 appBarActions: [ 410 - Padding( 411 - padding: const EdgeInsets.only(right: 8), 412 - child: Container( 413 - padding: const EdgeInsets.all(8), 414 - child: AppIcons.moreHoriz(color: colorScheme.onSurface), 431 + IconButton( 432 + onPressed: () {}, 433 + padding: EdgeInsets.zero, 434 + constraints: const BoxConstraints(), 435 + splashColor: Colors.transparent, 436 + highlightColor: Colors.transparent, 437 + icon: AppIcons.moreHoriz( 438 + color: colorScheme.onSurface, 439 + size: 28, 415 440 ), 416 441 ), 417 442 ], ··· 442 467 appBarActions: isCurrentUser 443 468 ? [ 444 469 Padding( 445 - padding: const EdgeInsets.only(right: 8), 470 + padding: const EdgeInsets.only(right: 4), 446 471 child: IconButton( 447 472 padding: EdgeInsets.zero, 473 + constraints: const BoxConstraints(), 474 + splashColor: Colors.transparent, 475 + highlightColor: Colors.transparent, 448 476 onPressed: () => 449 477 context.router.push(const SettingsRoute()), 450 - icon: AppIcons.gear(color: colorScheme.onSurface), 478 + icon: AppIcons.gear( 479 + color: colorScheme.onSurface, 480 + size: 28, 481 + ), 451 482 ), 452 483 ), 453 484 ]
+7 -1
lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart
··· 19 19 @PathParam('did') required this.did, 20 20 required this.videosOnly, 21 21 required this.initialPostIndex, 22 + this.bsky = false, 22 23 super.key, 23 24 }); 24 25 final String did; 25 26 final bool videosOnly; 26 27 final int initialPostIndex; 28 + 29 + /// Whether to use Bluesky API instead of Spark API. 30 + final bool bsky; 27 31 28 32 @override 29 33 ConsumerState<StandaloneProfileFeedPage> createState() => ··· 63 67 } 64 68 65 69 final feedState = ref.watch( 66 - profileFeedProvider(profileAtUri, widget.videosOnly), 70 + profileFeedProvider(profileAtUri, widget.videosOnly, widget.bsky), 67 71 ); 68 72 final bottomPadding = MediaQuery.of(context).padding.bottom; 69 73 ··· 112 116 profileFeedProvider( 113 117 profileAtUri, 114 118 widget.videosOnly, 119 + widget.bsky, 115 120 ).notifier, 116 121 ) 117 122 .loadMore(); ··· 156 161 profileFeedProvider( 157 162 profileAtUri, 158 163 widget.videosOnly, 164 + widget.bsky, 159 165 ).notifier, 160 166 ) 161 167 .refresh();
+19 -3
lib/src/features/profile/ui/pages/standalone_reposts_feed_page.dart
··· 18 18 const StandaloneRepostsFeedPage({ 19 19 @PathParam('did') required this.did, 20 20 required this.initialPostIndex, 21 + this.bsky = false, 21 22 super.key, 22 23 }); 23 24 final String did; 24 25 final int initialPostIndex; 26 + 27 + /// Whether to use Bluesky API instead of Spark API. 28 + final bool bsky; 25 29 26 30 @override 27 31 ConsumerState<StandaloneRepostsFeedPage> createState() => ··· 58 62 }); 59 63 } 60 64 61 - final repostsState = ref.watch(profileRepostsProvider(widget.did)); 65 + final repostsState = ref.watch( 66 + profileRepostsProvider(widget.did, widget.bsky), 67 + ); 62 68 final bottomPadding = MediaQuery.of(context).padding.bottom; 63 69 64 70 return Scaffold( ··· 102 108 if (index >= filteredUris.length - 3 && 103 109 !state.isEndOfNetwork) { 104 110 ref 105 - .read(profileRepostsProvider(widget.did).notifier) 111 + .read( 112 + profileRepostsProvider( 113 + widget.did, 114 + widget.bsky, 115 + ).notifier, 116 + ) 106 117 .loadMore(); 107 118 } 108 119 }, ··· 143 154 ElevatedButton( 144 155 onPressed: () { 145 156 ref 146 - .read(profileRepostsProvider(widget.did).notifier) 157 + .read( 158 + profileRepostsProvider( 159 + widget.did, 160 + widget.bsky, 161 + ).notifier, 162 + ) 147 163 .refresh(); 148 164 }, 149 165 child: const Text('Retry'),
+7
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 273 273 if (post.author.did == currentProfileDid) { 274 274 context.router.maybePop(); 275 275 } else { 276 + // Check if post is from Bluesky 277 + final isBskyPost = post.uri.collection 278 + .toString() 279 + .startsWith( 280 + 'app.bsky', 281 + ); 276 282 // Otherwise, navigate to the new profile 277 283 context.router.push( 278 284 ProfileRoute( 279 285 did: post.author.did, 280 286 initialProfile: post.author, 287 + bsky: isBskyPost, 281 288 ), 282 289 ); 283 290 }
+9 -1
lib/src/features/profile/ui/widgets/profile_grid_tab.dart
··· 12 12 class ProfileGridTab extends ProfileTabBase { 13 13 const ProfileGridTab({ 14 14 required this.profileUri, 15 + this.bsky = false, 15 16 super.key, 16 17 }); 17 18 18 19 final AtUri profileUri; 19 20 21 + /// Whether to use Bluesky API instead of Spark API. 22 + final bool bsky; 23 + 20 24 @override 21 25 List<Widget> buildSlivers(BuildContext context, WidgetRef ref) { 22 26 void onPostTap(BuildContext context, WidgetRef ref, AtUri postUri) { 23 - ref.read(profileFeedProvider(profileUri, false)).whenData((feedState) { 27 + ref.read(profileFeedProvider(profileUri, false, bsky)).whenData(( 28 + feedState, 29 + ) { 24 30 final filteredUris = feedState.loadedPosts; 25 31 final postIndex = filteredUris.indexOf(postUri); 26 32 if (postIndex != -1) { ··· 29 35 did: profileUri.hostname, 30 36 videosOnly: false, 31 37 initialPostIndex: postIndex, 38 + bsky: bsky, 32 39 ), 33 40 ); 34 41 } else { ··· 46 53 videosOnly: false, 47 54 both: true, 48 55 onPostTap: onPostTap, 56 + bsky: bsky, 49 57 ); 50 58 } 51 59
+11 -2
lib/src/features/profile/ui/widgets/profile_grid_widget.dart
··· 16 16 required bool videosOnly, 17 17 required Function(BuildContext, WidgetRef, AtUri) onPostTap, 18 18 bool both = false, 19 + bool bsky = false, 19 20 }) { 20 - final feedState = ref.watch(profileFeedProvider(profileUri, videosOnly)); 21 + final feedState = ref.watch( 22 + profileFeedProvider(profileUri, videosOnly, bsky), 23 + ); 21 24 22 25 return feedState.when( 23 26 data: (state) { ··· 124 127 const SizedBox(height: 16), 125 128 ElevatedButton( 126 129 onPressed: () => ref 127 - .read(profileFeedProvider(profileUri, videosOnly).notifier) 130 + .read( 131 + profileFeedProvider( 132 + profileUri, 133 + videosOnly, 134 + bsky, 135 + ).notifier, 136 + ) 128 137 .refresh(), 129 138 child: const Text('Retry'), 130 139 ),
+11 -7
lib/src/features/profile/ui/widgets/profile_reposts_tab.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:auto_route/auto_route.dart'; 3 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 3 import 'package:flutter/material.dart'; 5 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 5 import 'package:skeletonizer/skeletonizer.dart'; 6 + import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 7 7 import 'package:spark/src/core/routing/app_router.dart'; 8 8 import 'package:spark/src/features/profile/providers/profile_reposts_provider.dart'; 9 9 import 'package:spark/src/features/profile/ui/widgets/profile_grid_widget.dart'; ··· 13 13 class ProfileRepostsTab extends ProfileTabBase { 14 14 const ProfileRepostsTab({ 15 15 required this.profileUri, 16 + this.bsky = false, 16 17 super.key, 17 18 }); 18 19 19 20 final AtUri profileUri; 20 21 22 + /// Whether to use Bluesky API instead of Spark API. 23 + final bool bsky; 24 + 21 25 @override 22 26 List<Widget> buildSlivers(BuildContext context, WidgetRef ref) { 23 27 // Extract actor identifier from profileUri (DID or handle) 24 28 final actor = profileUri.hostname; 25 29 26 30 void onPostTap(BuildContext context, WidgetRef ref, AtUri postUri) { 27 - ref.read(profileRepostsProvider(actor)).whenData((repostsState) { 31 + ref.read(profileRepostsProvider(actor, bsky)).whenData((repostsState) { 28 32 final filteredUris = repostsState.loadedPosts; 29 33 final postIndex = filteredUris.indexOf(postUri); 30 34 if (postIndex != -1) { ··· 32 36 StandaloneRepostsFeedRoute( 33 37 did: actor, 34 38 initialPostIndex: postIndex, 39 + bsky: bsky, 35 40 ), 36 41 ); 37 42 } else { ··· 64 69 required String actor, 65 70 required Function(BuildContext, WidgetRef, AtUri) onPostTap, 66 71 }) { 67 - final repostsState = ref.watch(profileRepostsProvider(actor)); 72 + final repostsState = ref.watch(profileRepostsProvider(actor, bsky)); 68 73 69 74 return repostsState.when( 70 75 data: (state) { ··· 78 83 child: Column( 79 84 mainAxisAlignment: MainAxisAlignment.center, 80 85 children: [ 81 - Icon( 82 - FluentIcons.arrow_repeat_all_24_regular, 86 + AppIcons.repost( 83 87 size: 48, 84 88 color: Theme.of(context).colorScheme.onSurfaceVariant, 85 89 ), ··· 161 165 child: Column( 162 166 mainAxisAlignment: MainAxisAlignment.center, 163 167 children: [ 164 - const Icon(FluentIcons.error_circle_24_regular, size: 48), 168 + const Icon(Icons.error_outline, size: 48), 165 169 const SizedBox(height: 16), 166 170 Text('Error loading reposts: $error'), 167 171 const SizedBox(height: 16), 168 172 ElevatedButton( 169 173 onPressed: () => ref 170 - .read(profileRepostsProvider(actor).notifier) 174 + .read(profileRepostsProvider(actor, bsky).notifier) 171 175 .refresh(), 172 176 child: const Text('Retry'), 173 177 ),
+12 -2
lib/src/features/profile/ui/widgets/user_list_view.dart
··· 26 26 @override 27 27 Widget build(BuildContext context, WidgetRef ref) { 28 28 if (users.isEmpty) { 29 - return const Center( 30 - child: Text('No users to display.'), 29 + return ListView( 30 + physics: const AlwaysScrollableScrollPhysics(), 31 + controller: scrollController, 32 + children: const [ 33 + Center( 34 + child: Padding( 35 + padding: EdgeInsets.all(16), 36 + child: Text('No users to display.'), 37 + ), 38 + ), 39 + ], 31 40 ); 32 41 } 33 42 34 43 return ListView.builder( 35 44 controller: scrollController, 45 + physics: const AlwaysScrollableScrollPhysics(), 36 46 itemCount: users.length + (isFetchingMore ? 1 : 0), 37 47 itemBuilder: (context, index) { 38 48 if (isFetchingMore && index == users.length) {
+2 -1
lib/src/features/settings/ui/pages/settings_page.dart
··· 222 222 style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), 223 223 ), 224 224 trailing: const Icon(FluentIcons.tag_24_regular), 225 - onTap: () => context.router.push(const LabelerManagementRoute()), 225 + onTap: () => 226 + context.router.push(const LabelerManagementRoute()), 226 227 contentPadding: const EdgeInsets.symmetric( 227 228 horizontal: 16, 228 229 vertical: 4,
-2
widgetbook/lib/templates/profile_page_template.dart
··· 164 164 onFollowingTap: () => print('Following tapped'), 165 165 onFollowTap: () => print('Follow tapped'), 166 166 onUnfollowTap: () => print('Unfollow tapped'), 167 - onShareTap: () => print('Share tapped'), 168 167 onEarlySupporterTap: () => print('Early supporter badge tapped'), 169 168 onMentionTap: (username) => print('Mention tapped: $username'), 170 169 appBarActions: [ ··· 221 220 onFollowingTap: () => print('Following tapped'), 222 221 onFollowTap: () => print('Follow tapped'), 223 222 onUnfollowTap: () => print('Unfollow tapped'), 224 - onShareTap: () => print('Share tapped'), 225 223 onEarlySupporterTap: () => print('Early supporter badge tapped'), 226 224 onMentionTap: (username) => print('Mention tapped: $username'), 227 225 appBarActions: [