[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: actor likes

+1050 -223
+1 -1
assets/icons/gear.svg
··· 1 - <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 1 + <svg width="24" height="24" viewBox="-3 -3 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 2 <path d="M10.582 1.4541C11.2494 0.57403 12.5311 0.51911 13.2754 1.28906L13.417 1.4541L14.166 2.44238C14.8823 3.38697 16.0525 3.86911 17.2256 3.70801L18.4541 3.54102H18.4551C19.6221 3.38089 20.6192 4.37806 20.459 5.54492L20.29 6.77344C20.1388 7.87384 20.5555 8.97077 21.3857 9.69336L21.5576 9.83301L22.5459 10.582C23.426 11.2494 23.4809 12.532 22.7109 13.2764L22.5459 13.418L21.5576 14.167C20.6137 14.8828 20.1287 16.0526 20.29 17.2266L20.459 18.4551C20.6192 19.6219 19.6221 20.6191 18.4551 20.459L17.2266 20.29C16.1262 20.1387 15.0285 20.5552 14.3057 21.3857L14.166 21.5576L13.417 22.5459C12.7496 23.4259 11.4678 23.4808 10.7236 22.7109L10.582 22.5459L9.83301 21.5576C9.1173 20.6139 7.94795 20.1291 6.77441 20.29H6.77344L5.54492 20.459C4.37761 20.6192 3.37991 19.6217 3.54004 18.4551L3.70898 17.2266C3.8704 16.0525 3.38548 14.8828 2.44141 14.167L1.4541 13.418C0.515299 12.7061 0.515295 11.2939 1.4541 10.582L2.44141 9.83301C3.38547 9.11717 3.87032 7.94737 3.70898 6.77344L3.54004 5.54492C3.37987 4.37831 4.37757 3.38075 5.54492 3.54102H5.5459L6.77441 3.70898C7.87404 3.85986 8.97057 3.44459 9.69336 2.61426L9.83301 2.44238L10.582 1.4541ZM11.9561 7.34277C9.40828 7.34278 7.34278 9.40828 7.34277 11.9561C7.34297 14.5036 9.40832 16.5693 11.9561 16.5693C14.5035 16.5691 16.5691 14.5035 16.5693 11.9561C16.5693 9.40832 14.5036 7.343 11.9561 7.34277Z" stroke="white" stroke-width="1.5"/> 3 3 </svg>
+12 -9
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 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: 28), 31 + return Padding( 32 + padding: const EdgeInsets.only(left: 4), 33 + child: SizedBox( 34 + width: 40, 35 + height: 40, 36 + child: Tooltip( 37 + message: tooltip ?? 'Back', 38 + child: GestureDetector( 39 + onTap: action, 40 + child: Center( 41 + child: AppIcons.chevronleft(color: iconColor, size: 28), 42 + ), 40 43 ), 41 44 ), 42 45 ),
+45
lib/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 4 + import 'package:spark/src/core/ui/foundation/colors.dart'; 5 + 6 + /// Design System overlay back button for full-screen/dark pages. 7 + /// 8 + /// Use this button when you need a back button positioned as an overlay 9 + /// (e.g., in a Stack) rather than in an AppBar. Matches the visual style 10 + /// of [AppLeadingButton] but designed for overlay contexts. 11 + class AppOverlayBackButton extends StatelessWidget { 12 + const AppOverlayBackButton({ 13 + super.key, 14 + this.color = AppColors.white, 15 + this.onPressed, 16 + }); 17 + 18 + /// Color for the icon. Defaults to white for dark/overlay screens. 19 + final Color color; 20 + 21 + /// Optional custom callback. If null, defaults to `context.router.maybePop()`. 22 + final VoidCallback? onPressed; 23 + 24 + @override 25 + Widget build(BuildContext context) { 26 + return SafeArea( 27 + child: Padding( 28 + padding: const EdgeInsets.only(left: 4), 29 + child: SizedBox( 30 + width: 40, 31 + height: 40, 32 + child: Tooltip( 33 + message: 'Back', 34 + child: GestureDetector( 35 + onTap: onPressed ?? () => context.router.maybePop(), 36 + child: Center( 37 + child: AppIcons.chevronleft(color: color, size: 28), 38 + ), 39 + ), 40 + ), 41 + ), 42 + ), 43 + ); 44 + } 45 + }
+63 -28
lib/src/core/design_system/templates/feeds_bar_template.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 2 3 import 'package:spark/src/core/design_system/components/molecules/feed_tag_list.dart'; 3 4 5 + /// The preferred height for the feeds bar content (excludes status bar). 6 + /// The actual rendered height includes top safe area padding. 7 + /// This widget is designed for use with [Scaffold.extendBodyBehindAppBar] = true. 8 + const kFeedsBarHeight = kToolbarHeight; 9 + 10 + /// The width of the leading button area, matching [kToolbarHeight] like AppBar. 11 + const kFeedsBarLeadingWidth = 40.0; 12 + 4 13 /// Design-only template for the Feeds Bar. 5 14 /// 6 - /// This template renders the tags row with an optional trailing action and 7 - /// a subtle top-to-bottom gradient backdrop. No providers or navigation here. 8 - class FeedsBarTemplate extends StatelessWidget { 15 + /// This template renders the tags row with a built-in leading button and 16 + /// a subtle top-to-bottom gradient backdrop. Implements [PreferredSizeWidget] 17 + /// so it can be used as an AppBar. 18 + /// 19 + /// The leading button is built-in (similar to how [AppBar] handles leading) 20 + /// and displays a create post icon. Use [onLeadingPressed] to handle taps. 21 + class FeedsBarTemplate extends StatelessWidget implements PreferredSizeWidget { 9 22 const FeedsBarTemplate({ 10 23 required this.tags, 11 24 this.selectedTagId, ··· 13 26 this.onReorder, 14 27 this.onLongPress, 15 28 this.enableReordering = false, 16 - this.action, 17 - this.height = kToolbarHeight, 29 + this.onLeadingPressed, 18 30 super.key, 19 31 }); 20 32 ··· 24 36 final Function(int oldIndex, int newIndex)? onReorder; 25 37 final Function(FeedTagData tag)? onLongPress; 26 38 final bool enableReordering; 27 - final Widget? action; 28 - final double height; 29 39 30 - // @override 31 - // Size get preferredSize => Size.fromHeight(height); 40 + /// Callback when the leading (create post) button is pressed. 41 + final VoidCallback? onLeadingPressed; 42 + 43 + /// Returns the toolbar height for layout calculations. 44 + /// The actual widget height includes status bar safe area padding. 45 + /// This matches [AppBar]'s behavior and works with [Scaffold.extendBodyBehindAppBar] = true. 46 + @override 47 + Size get preferredSize => const Size.fromHeight(kFeedsBarHeight); 32 48 33 49 @override 34 50 Widget build(BuildContext context) { 35 - const double tagStartInset = 20; 36 - return SizedBox( 37 - height: 30 + kToolbarHeight, 38 - child: Stack( 39 - fit: StackFit.expand, 40 - children: [ 41 - // Backdrop gradient from top (slightly dark) to transparent 42 - const DecoratedBox( 51 + // Use a Column to let the widget size naturally based on SafeArea + content 52 + // This avoids the preferredSize mismatch issue 53 + return Stack( 54 + clipBehavior: Clip.none, 55 + children: [ 56 + // Backdrop gradient - positioned to fill available space 57 + const Positioned.fill( 58 + child: DecoratedBox( 43 59 decoration: BoxDecoration( 44 60 gradient: LinearGradient( 45 61 begin: Alignment.topCenter, 46 62 end: Alignment.bottomCenter, 47 - colors: [Color.fromARGB(110, 0, 0, 0), Colors.transparent], 63 + colors: [ 64 + Color.fromARGB(110, 0, 0, 0), 65 + Colors.transparent, 66 + ], 48 67 ), 49 68 ), 50 69 ), 51 - SafeArea( 52 - bottom: false, 70 + ), 71 + // Content with SafeArea - this determines the widget's intrinsic height 72 + SafeArea( 73 + bottom: false, 74 + left: false, 75 + right: false, 76 + child: SizedBox( 77 + height: kFeedsBarHeight, 53 78 child: Padding( 54 - padding: const EdgeInsets.only(right: 12), 79 + padding: const EdgeInsets.only(left: 4), 55 80 child: Row( 56 81 children: [ 82 + // Leading button - matches AppLeadingButton structure exactly 83 + SizedBox( 84 + width: kFeedsBarLeadingWidth, 85 + height: kFeedsBarLeadingWidth, 86 + child: Tooltip( 87 + message: 'Create post', 88 + child: GestureDetector( 89 + onTap: onLeadingPressed, 90 + child: Center( 91 + child: AppIcons.addPostFilled(size: 28), 92 + ), 93 + ), 94 + ), 95 + ), 96 + const SizedBox(width: 8), 57 97 Expanded( 58 98 child: FeedTagList( 59 99 tags: tags, ··· 62 102 onReorder: onReorder, 63 103 onLongPress: onLongPress, 64 104 enableReordering: enableReordering, 65 - leadingSpacing: tagStartInset, 66 105 enableRightFade: true, 67 106 ), 68 107 ), 69 - if (action != null) ...[ 70 - const SizedBox(width: 8), 71 - action!, 72 - ], 73 108 ], 74 109 ), 75 110 ), 76 111 ), 77 - ], 78 - ), 112 + ), 113 + ], 79 114 ); 80 115 } 81 116 }
-1
lib/src/core/design_system/templates/profile_page_template.dart
··· 88 88 return Scaffold( 89 89 appBar: AppBar( 90 90 centerTitle: isCurrentUser, 91 - leadingWidth: 40, 92 91 title: appBarTitle != null 93 92 ? Text( 94 93 appBarTitle!,
+13
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 229 229 String? cursor, 230 230 bool bluesky = false, 231 231 }); 232 + 233 + /// Get a list of posts liked by an actor 234 + /// 235 + /// [actor] The at-identifier of the actor (handle or DID) 236 + /// [limit] The number of items to return (default 50, max 100) 237 + /// [cursor] Pagination cursor for the next set of results 238 + /// [bluesky] Whether to fetch from Bluesky API instead of Spark 239 + Future<({List<FeedViewPost> posts, String? cursor})> getActorLikes( 240 + String actor, { 241 + int limit = 50, 242 + String? cursor, 243 + bool bluesky = false, 244 + }); 232 245 }
+78
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 1600 1600 return (posts: <FeedViewPost>[], cursor: null); 1601 1601 } 1602 1602 1603 + @override 1604 + Future<({List<FeedViewPost> posts, String? cursor})> getActorLikes( 1605 + String actor, { 1606 + int limit = 50, 1607 + String? cursor, 1608 + bool bluesky = false, 1609 + }) async { 1610 + _logger.d( 1611 + 'Getting actor likes for actor: $actor, limit: $limit, ' 1612 + 'cursor: $cursor, bluesky: $bluesky', 1613 + ); 1614 + 1615 + if (bluesky) { 1616 + return _getActorLikesFromBluesky(actor, limit: limit, cursor: cursor); 1617 + } 1618 + 1619 + return _client.executeWithRetry(() async { 1620 + if (!_client.authRepository.isAuthenticated) { 1621 + _logger.w('Not authenticated'); 1622 + throw Exception('Not authenticated'); 1623 + } 1624 + 1625 + final atproto = _client.authRepository.atproto; 1626 + if (atproto == null) { 1627 + _logger.e('AtProto not initialized'); 1628 + throw Exception('AtProto not initialized'); 1629 + } 1630 + 1631 + final parameters = <String, dynamic>{ 1632 + 'actor': actor, 1633 + 'limit': limit, 1634 + }; 1635 + 1636 + if (cursor != null) { 1637 + parameters['cursor'] = cursor; 1638 + } 1639 + 1640 + final result = await atproto.get( 1641 + NSID.parse('so.sprk.feed.getActorLikes'), 1642 + parameters: parameters, 1643 + headers: {'atproto-proxy': _client.sprkDid}, 1644 + to: (jsonMap) { 1645 + final rawFeed = jsonMap['feed']! as List<dynamic>; 1646 + final feedPosts = _parseAndFilterPosts<FeedViewPost>( 1647 + rawPosts: rawFeed, 1648 + fromJson: FeedViewPost.fromJson, 1649 + hasMedia: _feedViewPostHasMedia, 1650 + getUri: _getFeedViewPostUri, 1651 + source: 'sprk actor likes', 1652 + ); 1653 + return (posts: feedPosts, cursor: jsonMap['cursor'] as String?); 1654 + }, 1655 + adaptor: (uint8) => 1656 + jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 1657 + ); 1658 + _logger.d( 1659 + 'Actor likes retrieved successfully: ' 1660 + '${result.data.posts.length} posts', 1661 + ); 1662 + return result.data; 1663 + }); 1664 + } 1665 + 1666 + /// Get actor likes from Bluesky API 1667 + /// Note: Bluesky doesn't have a direct getActorLikes endpoint, 1668 + /// so we return an empty result in Bluesky mode. 1669 + Future<({List<FeedViewPost> posts, String? cursor})> 1670 + _getActorLikesFromBluesky( 1671 + String actor, { 1672 + required int limit, 1673 + required String? cursor, 1674 + }) async { 1675 + _logger.w( 1676 + 'getActorLikes is not available for Bluesky API, returning empty', 1677 + ); 1678 + return (posts: <FeedViewPost>[], cursor: null); 1679 + } 1680 + 1603 1681 /// Helper method to determine content type based on file extension 1604 1682 String _getContentType(String videoPath) { 1605 1683 final extension = path.extension(videoPath).toLowerCase();
+4
lib/src/core/routing/app_router.dart
··· 125 125 page: StandaloneRepostsFeedRoute.page, 126 126 path: '/profile/:did/reposts', 127 127 ), 128 + AutoRoute( 129 + page: StandaloneLikesFeedRoute.page, 130 + path: '/profile/:did/likes', 131 + ), 128 132 AutoRoute(page: UserListRoute.page, path: '/profile/:did/users'), 129 133 AutoRoute(page: VideoReviewRoute.page, path: '/video-review'), 130 134 AutoRoute(page: ImageReviewRoute.page, path: '/image-review'),
+1
lib/src/core/routing/pages.dart
··· 20 20 export 'package:spark/src/features/profile/ui/pages/blocks_page.dart'; 21 21 export 'package:spark/src/features/profile/ui/pages/edit_profile_page.dart'; 22 22 export 'package:spark/src/features/profile/ui/pages/profile_page.dart'; 23 + export 'package:spark/src/features/profile/ui/pages/standalone_likes_feed_page.dart'; 23 24 export 'package:spark/src/features/profile/ui/pages/standalone_profile_feed_page.dart'; 24 25 export 'package:spark/src/features/profile/ui/pages/standalone_reposts_feed_page.dart'; 25 26 export 'package:spark/src/features/profile/ui/pages/user_profile_page.dart';
+2 -15
lib/src/features/auth/ui/pages/login_page.dart
··· 4 4 import 'package:flutter/services.dart'; 5 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 6 import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; 7 + import 'package:spark/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart'; 7 8 import 'package:spark/src/core/design_system/components/atoms/buttons/long_button.dart'; 8 - import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 9 9 import 'package:spark/src/core/design_system/tokens/typography.dart'; 10 10 import 'package:spark/src/core/routing/app_router.dart'; 11 11 import 'package:spark/src/features/auth/providers/auth_providers.dart'; ··· 276 276 Positioned( 277 277 top: 0, 278 278 left: 0, 279 - child: SafeArea( 280 - child: Padding( 281 - padding: const EdgeInsets.all(8), 282 - child: IconButton( 283 - icon: AppIcons.chevronleft(color: colorScheme.onSurface), 284 - onPressed: () { 285 - if (mounted) { 286 - context.router.maybePop(); 287 - } 288 - }, 289 - tooltip: 'Back', 290 - ), 291 - ), 292 - ), 279 + child: AppOverlayBackButton(color: colorScheme.onSurface), 293 280 ), 294 281 ], 295 282 ),
+2 -5
lib/src/features/comments/ui/pages/replies_page.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'; 5 + import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; 6 6 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 7 7 import 'package:spark/src/features/comments/providers/comments_page_provider.dart'; 8 8 import 'package:spark/src/features/comments/ui/widgets/comment_input.dart'; ··· 75 75 'Replies', 76 76 style: TextStyle(color: textColor, fontWeight: FontWeight.bold), 77 77 ), 78 - leading: IconButton( 79 - onPressed: () => context.router.maybePop(), 80 - icon: Icon(FluentIcons.arrow_left_24_regular, color: textColor), 81 - ), 78 + leading: AppLeadingButton(color: textColor), 82 79 ), 83 80 body: state.when( 84 81 data: (data) => SafeArea(
+9 -20
lib/src/features/feed/ui/pages/feeds_page.dart
··· 148 148 149 149 return Scaffold( 150 150 backgroundColor: AppColors.black, 151 - body: Stack( 152 - children: [ 153 - if (_pageController != null && feeds.isNotEmpty) 154 - PageView.builder( 151 + extendBodyBehindAppBar: true, 152 + appBar: _pageController != null 153 + ? FeedsBar(pageController: _pageController!) 154 + : null, 155 + body: _pageController != null && feeds.isNotEmpty 156 + ? PageView.builder( 155 157 controller: _pageController, 156 158 itemCount: feeds.length, 157 159 onPageChanged: (index) { ··· 181 183 ); 182 184 }, 183 185 ) 184 - else 185 - // Show black background briefly while feeds initialize 186 - // The FeedPage will show skeleton once it renders 187 - const DecoratedBox( 186 + : const DecoratedBox( 187 + // Show black background briefly while feeds initialize 188 + // The FeedPage will show skeleton once it renders 188 189 decoration: BoxDecoration(color: AppColors.black), 189 190 ), 190 - // Always show FeedsBar once we have a controller 191 - // The controller is created as soon as we have activeFeed, 192 - // keeping it visible through the initialization transition 193 - if (_pageController != null) 194 - Positioned( 195 - top: 0, 196 - left: 0, 197 - right: 0, 198 - child: FeedsBar(pageController: _pageController!), 199 - ), 200 - ], 201 - ), 202 191 ); 203 192 } 204 193 }
+3 -10
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 7 import 'package:get_it/get_it.dart'; 8 + import 'package:spark/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart'; 8 9 import 'package:spark/src/core/design_system/tokens/constants.dart'; 9 10 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 10 11 import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; ··· 272 273 body: Stack( 273 274 children: [ 274 275 content, 275 - Positioned( 276 + const Positioned( 276 277 top: 0, 277 278 left: 0, 278 - child: SafeArea( 279 - child: Padding( 280 - padding: const EdgeInsets.all(8), 281 - child: IconButton( 282 - icon: const Icon(Icons.arrow_back, color: Colors.white), 283 - onPressed: () => context.router.maybePop(), 284 - ), 285 - ), 286 - ), 279 + child: AppOverlayBackButton(), 287 280 ), 288 281 ], 289 282 ),
+5 -14
lib/src/features/feed/ui/widgets/feed/feeds_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 - import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 4 3 import 'package:spark/src/core/design_system/components/molecules/create_media_sheet.dart'; 5 4 import 'package:spark/src/core/design_system/components/molecules/feed_tag_list.dart'; 6 5 import 'package:spark/src/core/design_system/templates/feeds_bar_template.dart'; ··· 9 8 import 'package:spark/src/features/feed/providers/feed_refresh_trigger_provider.dart'; 10 9 import 'package:spark/src/features/settings/providers/settings_provider.dart'; 11 10 11 + export 'package:spark/src/core/design_system/templates/feeds_bar_template.dart' 12 + show kFeedsBarHeight; 13 + 12 14 class FeedsBar extends ConsumerStatefulWidget implements PreferredSizeWidget { 13 15 const FeedsBar({required this.pageController, super.key}); 14 16 15 17 final PageController pageController; 16 18 17 19 @override 18 - Size get preferredSize => const Size.fromHeight(kToolbarHeight); 20 + Size get preferredSize => const Size.fromHeight(kFeedsBarHeight); 19 21 20 22 @override 21 23 ConsumerState<FeedsBar> createState() => _FeedsBarState(); ··· 186 188 return FeedsBarTemplate( 187 189 tags: tags, 188 190 selectedTagId: settings.activeFeed.config.id, 191 + onLeadingPressed: () => _showCreateMenu(context), 189 192 onTagTap: (tagId) { 190 193 final feed = pinnedFeeds.firstWhere( 191 194 (f) => f.config.id == tagId, ··· 207 210 ); 208 211 _showFeedOptionsSheet(context, feed); 209 212 }, 210 - action: SizedBox( 211 - width: 40, 212 - height: 40, 213 - child: IconButton( 214 - padding: EdgeInsets.zero, 215 - constraints: const BoxConstraints(), 216 - splashColor: Colors.transparent, 217 - highlightColor: Colors.transparent, 218 - onPressed: () => _showCreateMenu(context), 219 - icon: AppIcons.addPostFilled(size: 30), 220 - ), 221 - ), 222 213 ); 223 214 } 224 215 }
+7 -1
lib/src/features/messages/ui/pages/chat_page.dart
··· 93 93 otherUserHandle: widget.otherUserHandle, 94 94 otherUserAvatar: widget.otherUserAvatar, 95 95 ), 96 - loading: () => const Center(child: CircularProgressIndicator()), 96 + loading: () { 97 + final theme = Theme.of(context); 98 + return Scaffold( 99 + backgroundColor: theme.colorScheme.surface, 100 + body: const Center(child: CircularProgressIndicator()), 101 + ); 102 + }, 97 103 error: (error, stack) => Center( 98 104 child: Column( 99 105 mainAxisAlignment: MainAxisAlignment.center,
+7 -1
lib/src/features/messages/ui/pages/messages_page.dart
··· 74 74 activityWidget: const ActivitiesTab(), 75 75 ); 76 76 }, 77 - loading: () => const Center(child: CircularProgressIndicator()), 77 + loading: () { 78 + final theme = Theme.of(context); 79 + return Scaffold( 80 + backgroundColor: theme.colorScheme.surface, 81 + body: const Center(child: CircularProgressIndicator()), 82 + ); 83 + }, 78 84 error: (error, stack) { 79 85 final theme = Theme.of(context); 80 86 return Center(
+6 -1
lib/src/features/messages/ui/pages/new_chat_search_page.dart
··· 212 212 return const Center(child: CircularProgressIndicator()); 213 213 } 214 214 if (state.error != null) { 215 + final theme = Theme.of(context); 216 + final colorScheme = theme.colorScheme; 215 217 return Center( 216 - child: Text(state.error!, style: const TextStyle(color: Colors.red)), 218 + child: Text( 219 + state.error!, 220 + style: TextStyle(color: colorScheme.error), 221 + ), 217 222 ); 218 223 } 219 224 if (state.query.isEmpty) {
+6 -4
lib/src/features/notifications/ui/pages/notifications_page.dart
··· 20 20 @override 21 21 Widget build(BuildContext context) { 22 22 final notificationState = ref.watch(notificationProvider()); 23 + final theme = Theme.of(context); 24 + final colorScheme = theme.colorScheme; 23 25 24 26 // Reset the flag when refreshing so we can mark new notifications as seen 25 27 if (notificationState.isRefreshing) { ··· 38 40 } 39 41 40 42 return Scaffold( 41 - backgroundColor: AppColors.black, 43 + backgroundColor: colorScheme.surface, 42 44 appBar: AppBar( 43 - backgroundColor: AppColors.black, 44 - title: const Text( 45 + backgroundColor: colorScheme.surface, 46 + title: Text( 45 47 'Notifications', 46 - style: TextStyle(color: Colors.white), 48 + style: TextStyle(color: colorScheme.onSurface), 47 49 ), 48 50 ), 49 51 body: const NotificationsList(),
+89 -64
lib/src/features/notifications/ui/widgets/notification_item.dart
··· 484 484 }), 485 485 // Show +N count if there are more 486 486 if (extraCount > 0) 487 - Transform.translate( 488 - offset: Offset(-authors.length * 8.0, 0), 489 - child: Container( 490 - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 491 - decoration: BoxDecoration( 492 - color: Colors.grey[800], 493 - borderRadius: BorderRadius.circular(12), 494 - ), 495 - child: Row( 496 - mainAxisSize: MainAxisSize.min, 497 - children: [ 498 - Text( 499 - '+$extraCount', 500 - style: const TextStyle( 501 - color: Colors.white70, 502 - fontSize: 12, 503 - fontWeight: FontWeight.w500, 504 - ), 487 + Builder( 488 + builder: (context) { 489 + final theme = Theme.of(context); 490 + final colorScheme = theme.colorScheme; 491 + return Transform.translate( 492 + offset: Offset(-authors.length * 8.0, 0), 493 + child: Container( 494 + padding: const EdgeInsets.symmetric( 495 + horizontal: 6, 496 + vertical: 2, 497 + ), 498 + decoration: BoxDecoration( 499 + color: colorScheme.surfaceContainerHighest, 500 + borderRadius: BorderRadius.circular(12), 505 501 ), 506 - const SizedBox(width: 2), 507 - const Icon( 508 - Icons.keyboard_arrow_down, 509 - size: 14, 510 - color: Colors.white54, 502 + child: Row( 503 + mainAxisSize: MainAxisSize.min, 504 + children: [ 505 + Text( 506 + '+$extraCount', 507 + style: TextStyle( 508 + color: colorScheme.onSurfaceVariant.withAlpha(179), 509 + fontSize: 12, 510 + fontWeight: FontWeight.w500, 511 + ), 512 + ), 513 + const SizedBox(width: 2), 514 + Icon( 515 + Icons.keyboard_arrow_down, 516 + size: 14, 517 + color: colorScheme.onSurfaceVariant.withAlpha(138), 518 + ), 519 + ], 511 520 ), 512 - ], 513 - ), 514 - ), 521 + ), 522 + ); 523 + }, 515 524 ), 516 525 ], 517 526 ), ··· 563 572 crossAxisAlignment: CrossAxisAlignment.start, 564 573 children: [ 565 574 // Username, action, and timestamp in one line 566 - Wrap( 567 - crossAxisAlignment: WrapCrossAlignment.center, 568 - spacing: 4, 569 - children: [ 570 - Text( 571 - username, 572 - style: const TextStyle( 573 - color: Colors.white, 574 - fontWeight: FontWeight.bold, 575 - fontSize: 15, 576 - ), 577 - ), 578 - Text( 579 - reasonText, 580 - style: const TextStyle( 581 - color: Colors.white70, 582 - fontSize: 15, 583 - ), 584 - ), 585 - Text( 586 - '· $timeAgo', 587 - style: const TextStyle( 588 - color: Colors.white38, 589 - fontSize: 14, 590 - ), 591 - ), 592 - ], 575 + Builder( 576 + builder: (context) { 577 + final theme = Theme.of(context); 578 + final colorScheme = theme.colorScheme; 579 + return Wrap( 580 + crossAxisAlignment: WrapCrossAlignment.center, 581 + spacing: 4, 582 + children: [ 583 + Text( 584 + username, 585 + style: TextStyle( 586 + color: colorScheme.onSurface, 587 + fontWeight: FontWeight.bold, 588 + fontSize: 15, 589 + ), 590 + ), 591 + Text( 592 + reasonText, 593 + style: TextStyle( 594 + color: colorScheme.onSurface.withAlpha(179), 595 + fontSize: 15, 596 + ), 597 + ), 598 + Text( 599 + '· $timeAgo', 600 + style: TextStyle( 601 + color: colorScheme.onSurface.withAlpha(102), 602 + fontSize: 14, 603 + ), 604 + ), 605 + ], 606 + ); 607 + }, 593 608 ), 594 609 // Content preview below (if available) 595 610 if (contentPreview != null && 596 611 contentPreview.isNotEmpty) ...[ 597 612 const SizedBox(height: 8), 598 - Text( 599 - contentPreview, 600 - style: const TextStyle( 601 - color: Colors.white60, 602 - fontSize: 14, 603 - ), 604 - maxLines: 3, 605 - overflow: TextOverflow.ellipsis, 613 + Builder( 614 + builder: (context) { 615 + final theme = Theme.of(context); 616 + final colorScheme = theme.colorScheme; 617 + return Text( 618 + contentPreview, 619 + style: TextStyle( 620 + color: colorScheme.onSurface.withAlpha(153), 621 + fontSize: 14, 622 + ), 623 + maxLines: 3, 624 + overflow: TextOverflow.ellipsis, 625 + ); 626 + }, 606 627 ), 607 628 ], 608 629 ], ··· 624 645 }, 625 646 loadingBuilder: (context, child, loadingProgress) { 626 647 if (loadingProgress == null) return child; 648 + final theme = Theme.of(context); 649 + final colorScheme = theme.colorScheme; 627 650 return Container( 628 651 width: 56, 629 652 height: 56, 630 - color: Colors.grey[800], 631 - child: const Center( 653 + color: colorScheme.surfaceContainerHighest, 654 + child: Center( 632 655 child: SizedBox( 633 656 width: 20, 634 657 height: 20, 635 658 child: CircularProgressIndicator( 636 659 strokeWidth: 2, 637 - color: Colors.white54, 660 + color: colorScheme.onSurfaceVariant.withAlpha( 661 + 138, 662 + ), 638 663 ), 639 664 ), 640 665 ),
+16 -12
lib/src/features/notifications/ui/widgets/notifications_list.dart
··· 73 73 74 74 if (hasError && isEmpty) { 75 75 final errorMsg = notificationState.errorMessage; 76 + final theme = Theme.of(context); 77 + final colorScheme = theme.colorScheme; 76 78 return RefreshIndicator( 77 79 onRefresh: () async { 78 80 await ref ··· 95 97 child: Column( 96 98 mainAxisAlignment: MainAxisAlignment.center, 97 99 children: [ 98 - const Icon( 100 + Icon( 99 101 Icons.error_outline, 100 102 size: 64, 101 - color: Colors.white54, 103 + color: colorScheme.onSurface.withAlpha(128), 102 104 ), 103 105 const SizedBox(height: 16), 104 - const Text( 106 + Text( 105 107 'Failed to load notifications', 106 108 style: TextStyle( 107 - color: Colors.white70, 109 + color: colorScheme.onSurface.withAlpha(179), 108 110 fontSize: 16, 109 111 ), 110 112 ), ··· 112 114 const SizedBox(height: 8), 113 115 Text( 114 116 errorMsg, 115 - style: const TextStyle( 116 - color: Colors.white38, 117 + style: TextStyle( 118 + color: colorScheme.onSurface.withAlpha(102), 117 119 fontSize: 12, 118 120 ), 119 121 textAlign: TextAlign.center, ··· 145 147 } 146 148 147 149 if (notificationState.notifications.isEmpty) { 150 + final theme = Theme.of(context); 151 + final colorScheme = theme.colorScheme; 148 152 return RefreshIndicator( 149 153 onRefresh: () async { 150 154 await ref ··· 163 167 physics: const AlwaysScrollableScrollPhysics(), 164 168 child: SizedBox( 165 169 height: MediaQuery.of(context).size.height * 0.8, 166 - child: const Center( 170 + child: Center( 167 171 child: Column( 168 172 mainAxisAlignment: MainAxisAlignment.center, 169 173 children: [ 170 174 Icon( 171 175 Icons.notifications_none, 172 176 size: 64, 173 - color: Colors.white38, 177 + color: colorScheme.onSurface.withAlpha(102), 174 178 ), 175 - SizedBox(height: 16), 179 + const SizedBox(height: 16), 176 180 Text( 177 181 'No notifications', 178 182 style: TextStyle( 179 - color: Colors.white70, 183 + color: colorScheme.onSurface.withAlpha(179), 180 184 fontSize: 18, 181 185 fontWeight: FontWeight.w500, 182 186 ), 183 187 ), 184 - SizedBox(height: 8), 188 + const SizedBox(height: 8), 185 189 Text( 186 190 "You're all caught up!", 187 191 style: TextStyle( 188 - color: Colors.white38, 192 + color: colorScheme.onSurface.withAlpha(102), 189 193 fontSize: 14, 190 194 ), 191 195 ),
+172
lib/src/features/profile/providers/profile_likes_provider.dart
··· 1 + import 'dart:collection'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:get_it/get_it.dart'; 5 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 + import 'package:spark/src/core/network/atproto/data/models/models.dart'; 7 + import 'package:spark/src/core/network/atproto/data/repositories/feed_repository.dart'; 8 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 9 + import 'package:spark/src/core/utils/logging/log_service.dart'; 10 + import 'package:spark/src/core/utils/logging/logger.dart'; 11 + import 'package:spark/src/features/profile/providers/profile_feed_state.dart'; 12 + 13 + part 'profile_likes_provider.g.dart'; 14 + 15 + @riverpod 16 + class ProfileLikes extends _$ProfileLikes { 17 + final FeedRepository _feedRepository = GetIt.instance<SprkRepository>().feed; 18 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 19 + 'ProfileLikes', 20 + ); 21 + bool _isLoading = false; 22 + late final String _actor; 23 + 24 + @override 25 + Future<ProfileFeedState> build(String actor, bool bsky) async { 26 + _actor = actor; 27 + try { 28 + final result = await _loadLikes( 29 + actor: actor, 30 + cursor: null, 31 + ); 32 + return result; 33 + } catch (e, stackTrace) { 34 + _logger.e( 35 + 'Error loading initial likes: $e', 36 + error: e, 37 + stackTrace: stackTrace, 38 + ); 39 + rethrow; 40 + } 41 + } 42 + 43 + /// Load likes from specified API (Spark by default, Bluesky if bsky=true) 44 + Future<ProfileFeedState> _loadLikes({ 45 + required String actor, 46 + required String? cursor, 47 + ProfileFeedState? currentState, 48 + }) async { 49 + final postSources = Map<AtUri, String>.from( 50 + currentState?.postSources ?? {}, 51 + ); 52 + final postTypes = Map<AtUri, bool>.from(currentState?.postTypes ?? {}); 53 + final postViews = Map<AtUri, PostView>.from(currentState?.postViews ?? {}); 54 + final allPosts = List<AtUri>.from(currentState?.allPosts ?? []); 55 + 56 + final newPosts = <PostView>[]; 57 + 58 + // Fetch from the specified API (Spark by default, Bluesky if bsky=true) 59 + final result = await _fetchFromSource( 60 + (cursor) => _feedRepository.getActorLikes( 61 + actor, 62 + limit: ProfileFeedState.fetchLimit, 63 + cursor: cursor, 64 + bluesky: bsky, 65 + ), 66 + cursor, 67 + bsky ? 'BlueskyActorLikes' : 'SparkActorLikes', 68 + ); 69 + 70 + for (final feedViewPost in result.posts) { 71 + final uri = feedViewPost.uri; 72 + if (!postViews.containsKey(uri)) { 73 + final postView = feedViewPost.asPost; 74 + if (postView != null) { 75 + newPosts.add(postView); 76 + // Determine source based on URI collection 77 + final isBlueskyPost = uri.collection.toString().startsWith( 78 + 'app.bsky', 79 + ); 80 + postSources[uri] = isBlueskyPost ? 'bsky' : 'sprk'; 81 + postTypes[uri] = postView.videoUrl.isNotEmpty; 82 + postViews[uri] = postView; 83 + } 84 + } 85 + } 86 + 87 + newPosts.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 88 + allPosts.addAll(newPosts.map((post) => post.uri)); 89 + 90 + // End of network when: 91 + // 1. API returns null cursor (no more pages) 92 + // 2. API returns fewer posts than requested (last page) 93 + // 3. No new posts were added (duplicates or empty response) 94 + final isEndOfNetwork = 95 + result.cursor == null || 96 + result.posts.length < ProfileFeedState.fetchLimit || 97 + (currentState != null && 98 + currentState.allPosts.length == allPosts.length); 99 + 100 + return ProfileFeedState( 101 + loadedPosts: allPosts, 102 + allPosts: allPosts, 103 + isEndOfNetwork: isEndOfNetwork, 104 + cursor: result.cursor, 105 + // ignore: prefer_collection_literals 106 + extraInfo: currentState?.extraInfo ?? LinkedHashMap(), 107 + postSources: postSources, 108 + postTypes: postTypes, 109 + postViews: postViews, 110 + ); 111 + } 112 + 113 + Future<({List<FeedViewPost> posts, String? cursor})> _fetchFromSource( 114 + Future<({List<FeedViewPost> posts, String? cursor})> Function( 115 + String? cursor, 116 + ) 117 + fetcher, 118 + String? cursor, 119 + String sourceName, 120 + ) async { 121 + try { 122 + final result = await fetcher(cursor); 123 + return result; 124 + } catch (e, stackTrace) { 125 + _logger.e( 126 + 'Failed to load from $sourceName: $e', 127 + error: e, 128 + stackTrace: stackTrace, 129 + ); 130 + return (posts: <FeedViewPost>[], cursor: cursor); 131 + } 132 + } 133 + 134 + Future<void> loadMore() async { 135 + if (_isLoading || (state.value?.isEndOfNetwork ?? true)) return; 136 + 137 + _isLoading = true; 138 + final currentState = state.value; 139 + if (currentState == null) { 140 + _isLoading = false; 141 + return; 142 + } 143 + 144 + try { 145 + final result = await _loadLikes( 146 + actor: _actor, 147 + cursor: currentState.cursor, 148 + currentState: currentState, 149 + ); 150 + 151 + state = AsyncValue.data(result); 152 + } catch (e) { 153 + _logger.e('Error loading more likes: $e'); 154 + state = AsyncValue.error(e, StackTrace.current); 155 + } finally { 156 + _isLoading = false; 157 + } 158 + } 159 + 160 + Future<void> refresh() async { 161 + try { 162 + final result = await _loadLikes( 163 + actor: _actor, 164 + cursor: null, 165 + ); 166 + state = AsyncValue.data(result); 167 + } catch (e) { 168 + _logger.e('Error refreshing likes: $e'); 169 + state = AsyncValue.error(e, StackTrace.current); 170 + } 171 + } 172 + }
+53 -15
lib/src/features/profile/ui/pages/profile_page.dart
··· 22 22 import 'package:spark/src/core/utils/text_formatter.dart'; 23 23 import 'package:spark/src/features/auth/providers/auth_providers.dart'; 24 24 import 'package:spark/src/features/profile/providers/profile_feed_provider.dart'; 25 + import 'package:spark/src/features/profile/providers/profile_likes_provider.dart'; 25 26 import 'package:spark/src/features/profile/providers/profile_provider.dart'; 26 27 import 'package:spark/src/features/profile/providers/profile_reposts_provider.dart'; 27 28 import 'package:spark/src/features/profile/ui/pages/user_list_page.dart'; 28 29 import 'package:spark/src/features/profile/ui/widgets/profile_grid_tab.dart'; 30 + import 'package:spark/src/features/profile/ui/widgets/profile_likes_tab.dart'; 29 31 import 'package:spark/src/features/profile/ui/widgets/profile_reposts_tab.dart'; 30 32 import 'package:spark/src/features/profile/ui/widgets/profile_tab_base.dart'; 31 33 ··· 90 92 ref 91 93 .read(profileRepostsProvider(actor, widget.bsky).notifier) 92 94 .loadMore(); 95 + } else if (_activeTabIndex == 2) { 96 + final actor = profileUri.hostname; 97 + ref.read(profileLikesProvider(actor, widget.bsky).notifier).loadMore(); 93 98 } 94 99 } 95 100 } ··· 114 119 profileUri: profileUri, 115 120 bsky: widget.bsky, 116 121 ); 122 + case 2: 123 + // Third tab - likes (only shown for current user) 124 + tabWidget = ProfileLikesTab( 125 + profileUri: profileUri, 126 + bsky: widget.bsky, 127 + ); 117 128 default: 118 129 // Fallback to first tab 119 130 tabWidget = ProfileGridTab(profileUri: profileUri, bsky: widget.bsky); ··· 208 219 } else if (_activeTabIndex == 1) { 209 220 final actor = profileUri.hostname; 210 221 ref.watch(profileRepostsProvider(actor, widget.bsky)); 222 + } else if (_activeTabIndex == 2) { 223 + final actor = profileUri.hostname; 224 + ref.watch(profileLikesProvider(actor, widget.bsky)); 211 225 } 212 226 213 227 // Build slivers for the active tab using cached widget ··· 331 345 highlightColor: Colors.transparent, 332 346 onPressed: () => _showCreateMenu(context), 333 347 icon: AppIcons.addPostFilled( 334 - size: 30, 348 + size: 28, 335 349 ), 336 350 ), 337 351 ) ··· 344 358 splashColor: Colors.transparent, 345 359 highlightColor: Colors.transparent, 346 360 onPressed: () => context.router.push(const SettingsRoute()), 347 - icon: AppIcons.gear(color: colorScheme.onSurface, size: 25), 361 + icon: AppIcons.gear(color: colorScheme.onSurface, size: 28), 348 362 ) 349 363 else 350 364 IconButton( ··· 428 442 ], 429 443 tabsWidget: ProfileTabBar( 430 444 selectedIndex: _activeTabIndex, 431 - tabs: _buildTabItems(context, _activeTabIndex), 445 + tabs: _buildTabItems( 446 + context, 447 + _activeTabIndex, 448 + isCurrentUser: isCurrentUser, 449 + ), 432 450 ), 433 451 onTabChanged: (index) { 434 452 setState(() { ··· 447 465 }, 448 466 loading: () { 449 467 final initial = widget.initialProfile; 468 + // Check if this is the current user's profile during loading 469 + final currentUserDid = ref.read(currentDidProvider); 470 + final isCurrentUserLoading = 471 + currentUserDid != null && currentUserDid == widget.did; 450 472 451 473 return ProfilePageTemplate( 452 474 isLoading: true, ··· 456 478 postsCount: '0', 457 479 followersCount: '0', 458 480 followingCount: '0', 459 - isCurrentUser: false, 481 + isCurrentUser: isCurrentUserLoading, 460 482 appBarTitle: initial?.handle ?? 'loading', 461 483 appBarActions: [ 462 484 IconButton( ··· 473 495 ], 474 496 tabsWidget: ProfileTabBar( 475 497 selectedIndex: _activeTabIndex, 476 - tabs: _buildTabItems(context, _activeTabIndex), 498 + tabs: _buildTabItems( 499 + context, 500 + _activeTabIndex, 501 + isCurrentUser: isCurrentUserLoading, 502 + ), 477 503 ), 478 504 contentWidget: 479 505 const SizedBox.shrink(), // Not used when contentSlivers provided ··· 523 549 // When adding tabs 1+, switch to AutoTabsRouter & pass TabsRouter not int 524 550 List<ProfileTabItem> _buildTabItems( 525 551 BuildContext context, 526 - int activeIndex, 527 - ) { 552 + int activeIndex, { 553 + bool isCurrentUser = false, 554 + }) { 528 555 final inactiveColor = Theme.of(context).colorScheme.onSurfaceVariant; 529 556 530 - return [ 557 + final tabs = [ 531 558 ProfileTabItem( 532 559 icon: AppIcons.grid(color: inactiveColor), 533 560 filledIcon: AppIcons.gridFilled(), ··· 549 576 }); 550 577 }, 551 578 ), 552 - // Add more tabs here (these will correspond to route pages): 553 - // ProfileTabItem( 554 - // icon: AppIcons.profileLiked(), 555 - // filledIcon: AppIcons.likeFilled(), 556 - // isSelected: activeIndex == 2, 557 - // onTap: () => tabsRouter.setActiveIndex(2), 558 - // ), 559 579 ]; 580 + 581 + // Only show likes tab for current user 582 + if (isCurrentUser) { 583 + tabs.add( 584 + ProfileTabItem( 585 + icon: AppIcons.profileLiked(color: inactiveColor), 586 + filledIcon: AppIcons.likeFilled(), 587 + isSelected: activeIndex == 2, 588 + onTap: () { 589 + setState(() { 590 + _activeTabIndex = 2; 591 + }); 592 + }, 593 + ), 594 + ); 595 + } 596 + 597 + return tabs; 560 598 } 561 599 562 600 Future<void> _openStoriesViewer(
+263
lib/src/features/profile/ui/pages/standalone_likes_feed_page.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:auto_route/auto_route.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 + import 'package:spark/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart'; 8 + import 'package:spark/src/core/design_system/tokens/constants.dart'; 9 + import 'package:spark/src/core/routing/app_router.dart'; 10 + import 'package:spark/src/core/ui/foundation/colors.dart'; 11 + import 'package:spark/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; 12 + import 'package:spark/src/features/feed/ui/widgets/feed/snappy_page_scroll_physics.dart'; 13 + import 'package:spark/src/features/profile/providers/profile_feed_index_provider.dart'; 14 + import 'package:spark/src/features/profile/providers/profile_likes_provider.dart'; 15 + import 'package:spark/src/features/profile/ui/widgets/profile_feed_post_widget.dart'; 16 + 17 + @RoutePage() 18 + class StandaloneLikesFeedPage extends ConsumerStatefulWidget { 19 + const StandaloneLikesFeedPage({ 20 + @PathParam('did') required this.did, 21 + required this.initialPostIndex, 22 + this.bsky = false, 23 + super.key, 24 + }); 25 + final String did; 26 + final int initialPostIndex; 27 + 28 + /// Whether to use Bluesky API instead of Spark API. 29 + final bool bsky; 30 + 31 + @override 32 + ConsumerState<StandaloneLikesFeedPage> createState() => 33 + _StandaloneLikesFeedPageState(); 34 + } 35 + 36 + class _StandaloneLikesFeedPageState 37 + extends ConsumerState<StandaloneLikesFeedPage> { 38 + late final PageController pageController; 39 + int _currentIndex = 0; 40 + bool _hasInitializedIndex = false; 41 + 42 + @override 43 + void initState() { 44 + super.initState(); 45 + _currentIndex = widget.initialPostIndex; 46 + pageController = PageController(initialPage: widget.initialPostIndex); 47 + } 48 + 49 + @override 50 + void dispose() { 51 + pageController.dispose(); 52 + super.dispose(); 53 + } 54 + 55 + @override 56 + Widget build(BuildContext context) { 57 + if (!_hasInitializedIndex) { 58 + _hasInitializedIndex = true; 59 + WidgetsBinding.instance.addPostFrameCallback((_) { 60 + ref 61 + .read(profileFeedIndexProvider('likes:${widget.did}').notifier) 62 + .setIndex(widget.initialPostIndex); 63 + }); 64 + } 65 + 66 + final likesState = ref.watch( 67 + profileLikesProvider(widget.did, widget.bsky), 68 + ); 69 + final bottomPadding = MediaQuery.of(context).padding.bottom; 70 + 71 + return Scaffold( 72 + backgroundColor: AppColors.black, 73 + body: Stack( 74 + children: [ 75 + // Full-screen content 76 + likesState.when( 77 + data: (state) { 78 + // Display all posts returned by server - no client-side filtering 79 + final filteredUris = state.loadedPosts; 80 + 81 + if (filteredUris.isEmpty) { 82 + return const Center( 83 + child: Text( 84 + 'No likes available', 85 + style: TextStyle(color: AppColors.white), 86 + ), 87 + ); 88 + } 89 + 90 + return CacheablePageView.builder( 91 + cachePageExtent: 1, 92 + controller: pageController, 93 + scrollDirection: Axis.vertical, 94 + physics: const SnappyPageScrollPhysics(), 95 + allowImplicitScrolling: true, 96 + itemCount: filteredUris.length, 97 + onPageChanged: (index) { 98 + setState(() { 99 + _currentIndex = index; 100 + }); 101 + ref 102 + .read( 103 + profileFeedIndexProvider( 104 + 'likes:${widget.did}', 105 + ).notifier, 106 + ) 107 + .setIndex(index); 108 + // Load more posts when approaching the end 109 + if (index >= filteredUris.length - 3 && 110 + !state.isEndOfNetwork) { 111 + ref 112 + .read( 113 + profileLikesProvider( 114 + widget.did, 115 + widget.bsky, 116 + ).notifier, 117 + ) 118 + .loadMore(); 119 + } 120 + }, 121 + itemBuilder: (context, index) { 122 + final postUri = filteredUris[index]; 123 + final post = state.postViews[postUri]; 124 + // Create a profile URI from the did for the post widget 125 + final profileUri = AtUri.parse('at://${widget.did}'); 126 + return ProfileFeedPostWidget( 127 + postUri: postUri, 128 + profileUri: profileUri, 129 + videosOnly: false, 130 + post: post, 131 + index: index, 132 + ); 133 + }, 134 + ); 135 + }, 136 + loading: () => const Center( 137 + child: CircularProgressIndicator(color: AppColors.white), 138 + ), 139 + error: (error, stack) => Center( 140 + child: Column( 141 + mainAxisAlignment: MainAxisAlignment.center, 142 + children: [ 143 + const Icon( 144 + Icons.error_outline, 145 + color: AppColors.white, 146 + size: 48, 147 + ), 148 + const SizedBox(height: 16), 149 + Text( 150 + 'Error loading likes: $error', 151 + style: const TextStyle(color: AppColors.white), 152 + textAlign: TextAlign.center, 153 + ), 154 + const SizedBox(height: 16), 155 + ElevatedButton( 156 + onPressed: () { 157 + ref 158 + .read( 159 + profileLikesProvider( 160 + widget.did, 161 + widget.bsky, 162 + ).notifier, 163 + ) 164 + .refresh(); 165 + }, 166 + child: const Text('Retry'), 167 + ), 168 + ], 169 + ), 170 + ), 171 + ), 172 + // Back button overlay 173 + const Positioned( 174 + top: 0, 175 + left: 0, 176 + child: AppOverlayBackButton(), 177 + ), 178 + ], 179 + ), 180 + bottomNavigationBar: _CommentBar( 181 + bottomPadding: bottomPadding, 182 + onTap: () { 183 + final state = likesState.value; 184 + if (state != null && state.loadedPosts.isNotEmpty) { 185 + final currentPostUri = state.loadedPosts[_currentIndex]; 186 + final post = state.postViews[currentPostUri]; 187 + if (post != null) { 188 + context.router.push( 189 + CommentsRoute( 190 + postUri: post.uri.toString(), 191 + isSprk: post.isSprk, 192 + post: post, 193 + ), 194 + ); 195 + } 196 + } 197 + }, 198 + ), 199 + ); 200 + } 201 + } 202 + 203 + class _CommentBar extends StatelessWidget { 204 + const _CommentBar({ 205 + required this.bottomPadding, 206 + required this.onTap, 207 + }); 208 + 209 + final double bottomPadding; 210 + final VoidCallback onTap; 211 + 212 + @override 213 + Widget build(BuildContext context) { 214 + return ClipRRect( 215 + child: BackdropFilter( 216 + filter: ImageFilter.blur( 217 + sigmaX: AppConstants.blurBottomBar.toDouble(), 218 + sigmaY: AppConstants.blurBottomBar.toDouble(), 219 + ), 220 + child: DecoratedBox( 221 + decoration: BoxDecoration( 222 + color: const Color.fromARGB(51, 0, 0, 0), 223 + border: Border( 224 + top: BorderSide( 225 + color: Colors.white.withValues(alpha: 0.08), 226 + width: 2, 227 + ), 228 + ), 229 + ), 230 + child: GestureDetector( 231 + behavior: HitTestBehavior.opaque, 232 + onTap: onTap, 233 + child: Container( 234 + padding: EdgeInsets.only( 235 + left: 16, 236 + right: 16, 237 + top: 12, 238 + bottom: 12 + bottomPadding, 239 + ), 240 + child: Container( 241 + padding: const EdgeInsets.symmetric( 242 + horizontal: 16, 243 + vertical: 12, 244 + ), 245 + decoration: BoxDecoration( 246 + color: Colors.white.withValues(alpha: 0.1), 247 + borderRadius: BorderRadius.circular(24), 248 + ), 249 + child: const Text( 250 + 'Add comment...', 251 + style: TextStyle( 252 + color: Colors.white54, 253 + fontSize: 14, 254 + ), 255 + ), 256 + ), 257 + ), 258 + ), 259 + ), 260 + ), 261 + ); 262 + } 263 + }
+4 -11
lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart
··· 4 4 import 'package:auto_route/auto_route.dart'; 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 + import 'package:spark/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart'; 7 8 import 'package:spark/src/core/design_system/tokens/constants.dart'; 8 9 import 'package:spark/src/core/routing/app_router.dart'; 9 10 import 'package:spark/src/core/ui/foundation/colors.dart'; ··· 172 173 ), 173 174 ), 174 175 ), 175 - // Back button overlay - respects safe area for the button only 176 - Positioned( 176 + // Back button overlay 177 + const Positioned( 177 178 top: 0, 178 179 left: 0, 179 - child: SafeArea( 180 - child: Padding( 181 - padding: const EdgeInsets.all(8), 182 - child: IconButton( 183 - icon: const Icon(Icons.arrow_back, color: AppColors.white), 184 - onPressed: () => context.router.maybePop(), 185 - ), 186 - ), 187 - ), 180 + child: AppOverlayBackButton(), 188 181 ), 189 182 ], 190 183 ),
+4 -11
lib/src/features/profile/ui/pages/standalone_reposts_feed_page.dart
··· 4 4 import 'package:auto_route/auto_route.dart'; 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 + import 'package:spark/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart'; 7 8 import 'package:spark/src/core/design_system/tokens/constants.dart'; 8 9 import 'package:spark/src/core/routing/app_router.dart'; 9 10 import 'package:spark/src/core/ui/foundation/colors.dart'; ··· 168 169 ), 169 170 ), 170 171 ), 171 - // Back button overlay - respects safe area for the button only 172 - Positioned( 172 + // Back button overlay 173 + const Positioned( 173 174 top: 0, 174 175 left: 0, 175 - child: SafeArea( 176 - child: Padding( 177 - padding: const EdgeInsets.all(8), 178 - child: IconButton( 179 - icon: const Icon(Icons.arrow_back, color: AppColors.white), 180 - onPressed: () => context.router.maybePop(), 181 - ), 182 - ), 183 - ), 176 + child: AppOverlayBackButton(), 184 177 ), 185 178 ], 186 179 ),
+185
lib/src/features/profile/ui/widgets/profile_likes_tab.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:skeletonizer/skeletonizer.dart'; 6 + import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 7 + import 'package:spark/src/core/routing/app_router.dart'; 8 + import 'package:spark/src/features/profile/providers/profile_likes_provider.dart'; 9 + import 'package:spark/src/features/profile/ui/widgets/profile_grid_widget.dart'; 10 + import 'package:spark/src/features/profile/ui/widgets/profile_tab_base.dart'; 11 + 12 + /// Tab widget that displays liked posts in a grid 13 + class ProfileLikesTab extends ProfileTabBase { 14 + const ProfileLikesTab({ 15 + required this.profileUri, 16 + this.bsky = false, 17 + super.key, 18 + }); 19 + 20 + final AtUri profileUri; 21 + 22 + /// Whether to use Bluesky API instead of Spark API. 23 + final bool bsky; 24 + 25 + @override 26 + List<Widget> buildSlivers(BuildContext context, WidgetRef ref) { 27 + // Extract actor identifier from profileUri (DID or handle) 28 + final actor = profileUri.hostname; 29 + 30 + void onPostTap(BuildContext context, WidgetRef ref, AtUri postUri) { 31 + ref.read(profileLikesProvider(actor, bsky)).whenData((likesState) { 32 + final filteredUris = likesState.loadedPosts; 33 + final postIndex = filteredUris.indexOf(postUri); 34 + if (postIndex != -1) { 35 + context.router.push( 36 + StandaloneLikesFeedRoute( 37 + did: actor, 38 + initialPostIndex: postIndex, 39 + bsky: bsky, 40 + ), 41 + ); 42 + } else { 43 + context.router.push( 44 + StandalonePostRoute(postUri: postUri.toString()), 45 + ); 46 + } 47 + }); 48 + } 49 + 50 + return _buildLikesGridSlivers( 51 + context: context, 52 + ref: ref, 53 + actor: actor, 54 + onPostTap: onPostTap, 55 + ); 56 + } 57 + 58 + @override 59 + Widget build(BuildContext context, WidgetRef ref) { 60 + // This widget is used by route pages to build slivers 61 + // The actual rendering happens in ProfilePageTemplate via buildSlivers() 62 + return const SizedBox.shrink(); 63 + } 64 + 65 + /// Builder function that creates slivers for the likes grid 66 + List<Widget> _buildLikesGridSlivers({ 67 + required BuildContext context, 68 + required WidgetRef ref, 69 + required String actor, 70 + required Function(BuildContext, WidgetRef, AtUri) onPostTap, 71 + }) { 72 + final likesState = ref.watch(profileLikesProvider(actor, bsky)); 73 + 74 + return likesState.when( 75 + data: (state) { 76 + // Display all posts returned by server - no client-side filtering 77 + final filteredUris = state.loadedPosts; 78 + 79 + if (filteredUris.isEmpty) { 80 + return [ 81 + SliverFillRemaining( 82 + child: Center( 83 + child: Column( 84 + mainAxisAlignment: MainAxisAlignment.center, 85 + children: [ 86 + AppIcons.likeFilled( 87 + size: 48, 88 + color: Theme.of(context).colorScheme.onSurfaceVariant, 89 + ), 90 + const SizedBox(height: 16), 91 + Text( 92 + 'No likes yet', 93 + style: Theme.of(context).textTheme.bodyLarge?.copyWith( 94 + color: Theme.of(context).colorScheme.onSurfaceVariant, 95 + ), 96 + ), 97 + ], 98 + ), 99 + ), 100 + ), 101 + ]; 102 + } 103 + 104 + return [ 105 + SliverPadding( 106 + padding: const EdgeInsets.all(5), 107 + sliver: SliverGrid( 108 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 109 + crossAxisCount: 3, 110 + crossAxisSpacing: 5, 111 + mainAxisSpacing: 5, 112 + childAspectRatio: 9 / 16, 113 + ), 114 + delegate: SliverChildBuilderDelegate( 115 + (context, index) { 116 + final postUri = filteredUris[index]; 117 + final postView = state.postViews[postUri]; 118 + final postSource = state.postSources[postUri]; 119 + 120 + if (postView == null) { 121 + return const SizedBox.shrink(); 122 + } 123 + 124 + return ProfileGridTile( 125 + postView: postView, 126 + postSource: postSource, 127 + onTap: () => onPostTap(context, ref, postUri), 128 + ); 129 + }, 130 + childCount: filteredUris.length, 131 + ), 132 + ), 133 + ), 134 + ]; 135 + }, 136 + loading: () => [ 137 + SliverPadding( 138 + padding: const EdgeInsets.all(5), 139 + sliver: SliverGrid( 140 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 141 + crossAxisCount: 3, 142 + crossAxisSpacing: 5, 143 + mainAxisSpacing: 5, 144 + childAspectRatio: 9 / 16, 145 + ), 146 + delegate: SliverChildBuilderDelegate( 147 + (context, index) => Skeletonizer( 148 + child: Container( 149 + decoration: BoxDecoration( 150 + color: Theme.of( 151 + context, 152 + ).colorScheme.surfaceContainerHighest, 153 + borderRadius: BorderRadius.circular(15), 154 + ), 155 + ), 156 + ), 157 + childCount: 12, 158 + ), 159 + ), 160 + ), 161 + ], 162 + error: (error, stack) => [ 163 + SliverFillRemaining( 164 + child: Center( 165 + child: Column( 166 + mainAxisAlignment: MainAxisAlignment.center, 167 + children: [ 168 + const Icon(Icons.error_outline, size: 48), 169 + const SizedBox(height: 16), 170 + Text('Error loading likes: $error'), 171 + const SizedBox(height: 16), 172 + ElevatedButton( 173 + onPressed: () => ref 174 + .read(profileLikesProvider(actor, bsky).notifier) 175 + .refresh(), 176 + child: const Text('Retry'), 177 + ), 178 + ], 179 + ), 180 + ), 181 + ), 182 + ], 183 + ); 184 + } 185 + }