From f5f8543bb154f71cba95d11e32c94517db36d8a1 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Tue, 16 Dec 2025 23:11:18 -0500 Subject: [PATCH] fix(profile): tabs overhaul for scroll improvements --- .../templates/profile_page_template.dart | 81 +-- lib/src/core/routing/app_router.dart | 6 - lib/src/core/routing/pages.dart | 1 - .../profile/ui/pages/profile_page.dart | 497 ++++++++++-------- .../profile/ui/pages/profile_videos_page.dart | 20 - .../profile/ui/widgets/profile_grid_tab.dart | 57 ++ .../ui/widgets/profile_grid_widget.dart | 276 ++++------ .../profile/ui/widgets/profile_tab_base.dart | 10 + 8 files changed, 503 insertions(+), 445 deletions(-) delete mode 100644 lib/src/features/profile/ui/pages/profile_videos_page.dart create mode 100644 lib/src/features/profile/ui/widgets/profile_grid_tab.dart create mode 100644 lib/src/features/profile/ui/widgets/profile_tab_base.dart diff --git a/lib/src/core/design_system/templates/profile_page_template.dart b/lib/src/core/design_system/templates/profile_page_template.dart index e17492e..c49e824 100644 --- a/lib/src/core/design_system/templates/profile_page_template.dart +++ b/lib/src/core/design_system/templates/profile_page_template.dart @@ -43,6 +43,8 @@ class ProfilePageTemplate extends StatelessWidget { this.selectedTabIndex = 0, this.onTabChanged, this.isLoading = false, + this.contentSlivers, + this.scrollController, }); final String displayName; @@ -73,8 +75,10 @@ class ProfilePageTemplate extends StatelessWidget { final int selectedTabIndex; final Function(int)? onTabChanged; final Widget contentWidget; + final List? contentSlivers; final Future Function()? onRefresh; final bool isLoading; + final ScrollController? scrollController; @override Widget build(BuildContext context) { @@ -92,43 +96,50 @@ class ProfilePageTemplate extends StatelessWidget { ), body: RefreshIndicator( onRefresh: onRefresh ?? () async {}, - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Skeletonizer( - enabled: isLoading, - child: _ProfileHeaderSection( - displayName: displayName, - handle: handle, - postsCount: postsCount, - followersCount: followersCount, - followingCount: followingCount, - avatarUrl: avatarUrl, - description: description, - links: links, - hasStories: hasStories, - isCurrentUser: isCurrentUser, - isFollowing: isFollowing, - isEarlySupporter: isEarlySupporter, - onAvatarTap: onAvatarTap, - onFollowersTap: onFollowersTap, - onFollowingTap: onFollowingTap, - onEditTap: onEditTap, - onFollowTap: onFollowTap, - onUnfollowTap: onUnfollowTap, - onShareTap: onShareTap, - onEarlySupporterTap: onEarlySupporterTap, - onMentionTap: onMentionTap, - onAddStoryTap: onAddStoryTap, + child: NotificationListener( + onNotification: (notification) { + // Handle scroll notifications for pagination if needed + return false; + }, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Skeletonizer( + enabled: isLoading, + child: _ProfileHeaderSection( + displayName: displayName, + handle: handle, + postsCount: postsCount, + followersCount: followersCount, + followingCount: followingCount, + avatarUrl: avatarUrl, + description: description, + links: links, + hasStories: hasStories, + isCurrentUser: isCurrentUser, + isFollowing: isFollowing, + isEarlySupporter: isEarlySupporter, + onAvatarTap: onAvatarTap, + onFollowersTap: onFollowersTap, + onFollowingTap: onFollowingTap, + onEditTap: onEditTap, + onFollowTap: onFollowTap, + onUnfollowTap: onUnfollowTap, + onShareTap: onShareTap, + onEarlySupporterTap: onEarlySupporterTap, + onMentionTap: onMentionTap, + onAddStoryTap: onAddStoryTap, + ), ), ), - ), - SliverPersistentHeader( - pinned: true, - delegate: StickyProfileTabBar(child: tabsWidget), - ), - SliverFillRemaining(child: contentWidget), - ], + SliverPersistentHeader( + pinned: true, + delegate: StickyProfileTabBar(child: tabsWidget), + ), + if (contentSlivers != null) ...contentSlivers! else SliverFillRemaining(child: contentWidget), + ], + ), ), ), ); diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart index e81b77a..7b25fd7 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -73,9 +73,6 @@ class AppRouter extends RootStackRouter { AutoRoute( page: UserProfileRoute.page, path: 'profile', - children: [ - AutoRoute(page: ProfileVideosRoute.page, path: 'videos'), - ], ), ], ), @@ -110,9 +107,6 @@ class AppRouter extends RootStackRouter { AutoRoute( page: ProfileRoute.page, path: '/profile/:did', - children: [ - AutoRoute(page: ProfileVideosRoute.page, path: 'videos', initial: true), - ], ), AutoRoute(page: UserListRoute.page, path: '/profile/:did/users'), AutoRoute(page: VideoReviewRoute.page, path: '/video-review'), diff --git a/lib/src/core/routing/pages.dart b/lib/src/core/routing/pages.dart index ece6c4e..106eb19 100644 --- a/lib/src/core/routing/pages.dart +++ b/lib/src/core/routing/pages.dart @@ -18,7 +18,6 @@ export 'package:sparksocial/src/features/posting/ui/pages/recording_page.dart'; export 'package:sparksocial/src/features/posting/ui/pages/video_review_page.dart'; export 'package:sparksocial/src/features/profile/ui/pages/edit_profile_page.dart'; export 'package:sparksocial/src/features/profile/ui/pages/profile_page.dart'; -export 'package:sparksocial/src/features/profile/ui/pages/profile_videos_page.dart'; export 'package:sparksocial/src/features/profile/ui/pages/standalone_profile_feed_page.dart'; export 'package:sparksocial/src/features/profile/ui/pages/user_profile_page.dart'; export 'package:sparksocial/src/features/search/ui/pages/search_page.dart'; diff --git a/lib/src/features/profile/ui/pages/profile_page.dart b/lib/src/features/profile/ui/pages/profile_page.dart index 760cba9..05dc339 100644 --- a/lib/src/features/profile/ui/pages/profile_page.dart +++ b/lib/src/features/profile/ui/pages/profile_page.dart @@ -1,3 +1,4 @@ +import 'package:atproto_core/atproto_core.dart'; import 'package:auto_route/auto_route.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; @@ -22,6 +23,7 @@ import 'package:sparksocial/src/features/profile/providers/profile_feed_provider import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; import 'package:sparksocial/src/features/profile/ui/widgets/early_supporter_sheet.dart'; +import 'package:sparksocial/src/features/profile/ui/widgets/profile_grid_tab.dart'; @RoutePage() class ProfilePage extends ConsumerStatefulWidget { @@ -43,12 +45,55 @@ class ProfilePage extends ConsumerStatefulWidget { class _ProfilePageState extends ConsumerState { late final SparkLogger _logger = GetIt.instance().getLogger('ProfilePage'); late final IdentityRepository _identityRepository = GetIt.instance(); + late final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } @override void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); super.dispose(); } + void _onScroll() { + // Trigger loading when user is within ~2 rows of the bottom (each row is roughly 200px at 9:16 aspect ratio) + if (_scrollController.hasClients && _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 500) { + final profileUri = AtUri.parse('at://${widget.did}'); + ref.read(profileFeedProvider(profileUri, false).notifier).loadMore(); + } + } + + /// Builds slivers for a given tab index + /// Tab 0 is built directly (default profile content), other tabs use route pages + List _buildSliversForTab({ + required BuildContext context, + required WidgetRef ref, + required int tabIndex, + }) { + final profileUri = AtUri.parse('at://${widget.did}'); + + switch (tabIndex) { + case 0: + // First tab - default profile grid content (not a route) + final gridTab = ProfileGridTab(profileUri: profileUri); + return gridTab.buildSlivers(context, ref); + // Add more tabs here - these correspond to route pages: + // case 1: + // return ProfileLikedPage.buildSlivers(context, ref, widget.did); + // case 2: + // return ProfileVideosOnlyPage.buildSlivers(context, ref, widget.did); + default: + // Fallback to first tab + final gridTab = ProfileGridTab(profileUri: profileUri); + return gridTab.buildSlivers(context, ref); + } + } + void _showEarlySupporterInfo(BuildContext context) { showModalBottomSheet( context: context, @@ -94,242 +139,262 @@ class _ProfilePageState extends ConsumerState { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - // Always render the AutoTabsRouter so the grid starts loading immediately - return AutoTabsRouter( - routes: [ - ProfileVideosRoute(did: widget.did), - ], - builder: (context, child) { - final tabsRouter = AutoTabsRouter.of(context); - - return profileStateAsync.when( - data: (state) { - if (state.showAuthPrompt) { - context.router.push(AuthPromptRoute(onClose: notifier.hideAuthPrompt)); - } + // Tab 0 is the default profile content (built directly, not a route) + // Tabs 1+ are subpages (route pages) + // For now we only have tab 0, so we use simple state management + // When adding tabs 1+, use AutoTabsRouter with those routes - final profile = state.profile; - if (profile == null) { - return ErrorScreen( - context: context, - message: 'Profile not found', - stackTrace: null, - onRetry: notifier.refreshProfile, - theme: theme, - ); - } - final isCurrentUser = notifier.isCurrentUser(); - final description = profile.description ?? ''; - final links = TextFormatter.extractUrls(description); - final uniqueLinks = links.toSet().toList(); - - return ProfilePageTemplate( - displayName: profile.displayName ?? profile.handle, - handle: profile.handle, - postsCount: TextFormatter.formatCount(profile.postsCount), - followersCount: TextFormatter.formatCount(profile.followersCount), - followingCount: TextFormatter.formatCount(profile.followsCount), - avatarUrl: profile.avatar?.toString(), - description: description.isNotEmpty ? description : null, - links: uniqueLinks.isNotEmpty ? uniqueLinks : null, - hasStories: profile.stories?.isNotEmpty ?? false, - isCurrentUser: isCurrentUser, - isFollowing: profile.viewer?.following != null, - isEarlySupporter: state.isEarlySupporter, - onAvatarTap: (profile.stories?.isNotEmpty ?? false) ? () => _openStoriesViewer(profile) : null, - onFollowersTap: () => context.router.push(UserListRoute(did: widget.did, type: UserListType.followers)), - onFollowingTap: () => context.router.push(UserListRoute(did: widget.did, type: UserListType.following)), - onEditTap: () { - context.router.push(EditProfileRoute(profile: profile)).then((updated) { - if (updated == true) { - notifier.refreshProfile(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Profile updated successfully')), - ); - } - } - }); - }, - onFollowTap: () async { - try { - await notifier.toggleFollow(); - final latestProfileState = ref.read(profileProvider(did: widget.did)).asData?.value; - - if (latestProfileState != null && !latestProfileState.showAuthPrompt) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Followed successfully'), - backgroundColor: Colors.green, - ), - ); - } - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), - ); - } + // Build slivers for tab 0 immediately - tabs load in parallel with profile + final contentSlivers = _buildSliversForTab( + context: context, + ref: ref, + tabIndex: 0, // Always tab 0 for now + ); + + return profileStateAsync.when( + data: (state) { + if (state.showAuthPrompt) { + context.router.push(AuthPromptRoute(onClose: notifier.hideAuthPrompt)); + } + + final profile = state.profile; + if (profile == null) { + return ErrorScreen( + context: context, + message: 'Profile not found', + stackTrace: null, + onRetry: notifier.refreshProfile, + theme: theme, + ); + } + final isCurrentUser = notifier.isCurrentUser(); + final description = profile.description ?? ''; + final links = TextFormatter.extractUrls(description); + final uniqueLinks = links.toSet().toList(); + + return ProfilePageTemplate( + displayName: profile.displayName ?? profile.handle, + handle: profile.handle, + postsCount: TextFormatter.formatCount(profile.postsCount), + followersCount: TextFormatter.formatCount(profile.followersCount), + followingCount: TextFormatter.formatCount(profile.followsCount), + avatarUrl: profile.avatar?.toString(), + description: description.isNotEmpty ? description : null, + links: uniqueLinks.isNotEmpty ? uniqueLinks : null, + hasStories: profile.stories?.isNotEmpty ?? false, + isCurrentUser: isCurrentUser, + isFollowing: profile.viewer?.following != null, + isEarlySupporter: state.isEarlySupporter, + onAvatarTap: (profile.stories?.isNotEmpty ?? false) ? () => _openStoriesViewer(profile) : null, + onFollowersTap: () => context.router.push(UserListRoute(did: widget.did, type: UserListType.followers)), + onFollowingTap: () => context.router.push(UserListRoute(did: widget.did, type: UserListType.following)), + onEditTap: () { + context.router.push(EditProfileRoute(profile: profile)).then((updated) { + if (updated == true) { + notifier.refreshProfile(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile updated successfully')), + ); } - }, - onUnfollowTap: () async { - try { - await notifier.toggleFollow(); - final latestProfileState = ref.read(profileProvider(did: widget.did)).asData?.value; - - if (latestProfileState != null && !latestProfileState.showAuthPrompt) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Unfollowed successfully'), - backgroundColor: Colors.green, - ), - ); - } - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), - ); - } + } + }); + }, + onFollowTap: () async { + try { + await notifier.toggleFollow(); + final latestProfileState = ref.read(profileProvider(did: widget.did)).asData?.value; + + if (latestProfileState != null && !latestProfileState.showAuthPrompt) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Followed successfully'), + backgroundColor: Colors.green, + ), + ); } - }, - onShareTap: () => _logger.i('Share profile tapped for ${profile.did}'), - onEarlySupporterTap: () => _showEarlySupporterInfo(context), - onMentionTap: _handleUsernameTap, - onAddStoryTap: isCurrentUser ? () => _handleAddStory(context) : null, - appBarTitle: profile.displayName ?? profile.handle, - appBarActions: [ - if (isCurrentUser) - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - padding: EdgeInsets.zero, - onPressed: () => context.router.push(const ProfileSettingsRoute()), - icon: Icon(FluentIcons.options_24_regular, color: colorScheme.onSurface), + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + ); + } + } + }, + onUnfollowTap: () async { + try { + await notifier.toggleFollow(); + final latestProfileState = ref.read(profileProvider(did: widget.did)).asData?.value; + + if (latestProfileState != null && !latestProfileState.showAuthPrompt) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Unfollowed successfully'), + backgroundColor: Colors.green, ), - ) - else - Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( - onTap: () => OptionsPanel.show( - context: context, - onReport: () => showDialog( - context: context, - useRootNavigator: false, - builder: (dContext) => ReportDialog( - postUri: 'at://${profile.did}/app.bsky.actor.profile/self', - postCid: profile.did, - onSubmit: (subject, reasonType, reason) async { - try { - final success = await notifier.createReport( - did: profile.did, - reasonType: reasonType, - reason: reason, - ); - if (success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Report submitted successfully')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error submitting report: $e')), - ); - } - } - }, - ), - ), - isProfile: true, - ), - child: Container( - padding: const EdgeInsets.all(8), - child: AppIcons.moreHoriz(color: colorScheme.onSurface), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + ); + } + } + }, + onShareTap: () => _logger.i('Share profile tapped for ${profile.did}'), + onEarlySupporterTap: () => _showEarlySupporterInfo(context), + onMentionTap: _handleUsernameTap, + onAddStoryTap: isCurrentUser ? () => _handleAddStory(context) : null, + appBarTitle: profile.displayName ?? profile.handle, + appBarActions: [ + if (isCurrentUser) + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + padding: EdgeInsets.zero, + onPressed: () => context.router.push(const ProfileSettingsRoute()), + icon: Icon(FluentIcons.options_24_regular, color: colorScheme.onSurface), + ), + ) + else + Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => OptionsPanel.show( + context: context, + onReport: () => showDialog( + context: context, + useRootNavigator: false, + builder: (dContext) => ReportDialog( + postUri: 'at://${profile.did}/app.bsky.actor.profile/self', + postCid: profile.did, + onSubmit: (subject, reasonType, reason) async { + try { + final success = await notifier.createReport( + did: profile.did, + reasonType: reasonType, + reason: reason, + ); + if (success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Report submitted successfully')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error submitting report: $e')), + ); + } + } + }, ), ), + isProfile: true, ), - ], - tabsWidget: ProfileTabBar( - selectedIndex: tabsRouter.activeIndex, - tabs: [ - ProfileTabItem( - icon: AppIcons.grid(), - filledIcon: AppIcons.gridFilled(), - isSelected: tabsRouter.activeIndex == 0, - onTap: () => tabsRouter.setActiveIndex(0), - ), - // ProfileTabItem( - // icon: AppIcons.profileLiked(), - // filledIcon: AppIcons.likeFilled(), - // isSelected: tabsRouter.activeIndex == 1, - // onTap: () => tabsRouter.setActiveIndex(1), - // ), - ], - ), - - selectedTabIndex: tabsRouter.activeIndex, - onTabChanged: tabsRouter.setActiveIndex, - contentWidget: child, - onRefresh: () async { - await notifier.refreshProfile(); - ref.invalidate(profileFeedProvider); - }, - ); - }, - loading: () { - final initial = widget.initialProfile; - - return ProfilePageTemplate( - isLoading: true, - displayName: initial?.displayName ?? initial?.handle ?? 'Loading...', - handle: initial?.handle ?? 'loading', - avatarUrl: initial?.avatar?.toString(), - postsCount: '0', - followersCount: '0', - followingCount: '0', - isCurrentUser: false, - appBarTitle: initial?.displayName ?? initial?.handle, - appBarActions: [ - Padding( - padding: const EdgeInsets.only(right: 8), child: Container( padding: const EdgeInsets.all(8), child: AppIcons.moreHoriz(color: colorScheme.onSurface), ), ), - ], - tabsWidget: ProfileTabBar( - selectedIndex: tabsRouter.activeIndex, - tabs: [ - ProfileTabItem( - icon: AppIcons.grid(), - filledIcon: AppIcons.gridFilled(), - isSelected: tabsRouter.activeIndex == 0, - onTap: () => tabsRouter.setActiveIndex(0), - ), - ], ), - contentWidget: child, - ); + ], + tabsWidget: ProfileTabBar( + selectedIndex: 0, // Always 0 for now + tabs: _buildTabItems(0), + ), + + selectedTabIndex: 0, + onTabChanged: (index) { + // When adding tabs 1+ with AutoTabsRouter, this will be: tabsRouter.setActiveIndex(index) + // For now with only tab 0, this is a no-op }, - error: (error, stackTrace) => ErrorScreen( - context: context, - message: error.toString(), - stackTrace: stackTrace, - onRetry: notifier.refreshProfile, - theme: theme, + contentWidget: const SizedBox.shrink(), // Not used when contentSlivers is provided + contentSlivers: contentSlivers, + scrollController: _scrollController, + onRefresh: () async { + await notifier.refreshProfile(); + ref.invalidate(profileFeedProvider); + }, + ); + }, + loading: () { + final initial = widget.initialProfile; + + return ProfilePageTemplate( + isLoading: true, + displayName: initial?.displayName ?? initial?.handle ?? 'Loading...', + handle: initial?.handle ?? 'loading', + avatarUrl: initial?.avatar?.toString(), + postsCount: '0', + followersCount: '0', + followingCount: '0', + isCurrentUser: false, + appBarTitle: initial?.displayName ?? initial?.handle, + appBarActions: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: Container( + padding: const EdgeInsets.all(8), + child: AppIcons.moreHoriz(color: colorScheme.onSurface), + ), + ), + ], + tabsWidget: ProfileTabBar( + selectedIndex: 0, // Always 0 for now + tabs: _buildTabItems(0), ), + contentWidget: const SizedBox.shrink(), // Not used when contentSlivers is provided + contentSlivers: contentSlivers, // Tabs load even while profile is loading + scrollController: _scrollController, ); }, + error: (error, stackTrace) => ErrorScreen( + context: context, + message: error.toString(), + stackTrace: stackTrace, + onRetry: notifier.refreshProfile, + theme: theme, + ), ); } + /// Builds the list of tab items - easy to add new tabs here! + /// When adding tabs 1+, switch to using AutoTabsRouter and pass TabsRouter instead of int + List _buildTabItems(int activeIndex) { + return [ + ProfileTabItem( + icon: AppIcons.grid(), + filledIcon: AppIcons.gridFilled(), + isSelected: activeIndex == 0, + onTap: () { + // When using AutoTabsRouter: tabsRouter.setActiveIndex(0) + setState(() { + // activeTabIndex = 0; // Will be handled by state management + }); + }, + ), + // Add more tabs here (these will correspond to route pages): + // ProfileTabItem( + // icon: AppIcons.profileLiked(), + // filledIcon: AppIcons.likeFilled(), + // isSelected: activeIndex == 1, + // onTap: () => tabsRouter.setActiveIndex(1), + // ), + // ProfileTabItem( + // icon: AppIcons.video(), + // filledIcon: AppIcons.videoFilled(), + // isSelected: activeIndex == 2, + // onTap: () => tabsRouter.setActiveIndex(2), + // ), + ]; + } + Future _openStoriesViewer(actor_models.ProfileViewDetailed profile) async { if (profile.stories?.isEmpty ?? true) return; diff --git a/lib/src/features/profile/ui/pages/profile_videos_page.dart b/lib/src/features/profile/ui/pages/profile_videos_page.dart deleted file mode 100644 index 473a57e..0000000 --- a/lib/src/features/profile/ui/pages/profile_videos_page.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:atproto_core/atproto_core.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sparksocial/src/features/profile/ui/widgets/profile_grid_widget.dart'; - -@RoutePage() -class ProfileVideosPage extends ConsumerWidget { - const ProfileVideosPage({@PathParam('did') required this.did, super.key}); - final String did; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ProfileGridWidget( - profileUri: AtUri.parse('at://$did'), - videosOnly: false, - both: true, - ); - } -} diff --git a/lib/src/features/profile/ui/widgets/profile_grid_tab.dart b/lib/src/features/profile/ui/widgets/profile_grid_tab.dart new file mode 100644 index 0000000..98f6829 --- /dev/null +++ b/lib/src/features/profile/ui/widgets/profile_grid_tab.dart @@ -0,0 +1,57 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sparksocial/src/core/routing/app_router.dart'; +import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; +import 'package:sparksocial/src/features/profile/ui/widgets/profile_grid_widget.dart'; +import 'package:sparksocial/src/features/profile/ui/widgets/profile_tab_base.dart'; + +/// Tab widget that displays all posts (images and videos) in a grid +/// This is the default profile tab (tab 0) - built directly, not via a route +class ProfileGridTab extends ProfileTabBase { + const ProfileGridTab({ + required this.profileUri, + super.key, + }); + + final AtUri profileUri; + + @override + List buildSlivers(BuildContext context, WidgetRef ref) { + void onPostTap(BuildContext context, WidgetRef ref, AtUri postUri) { + final feedState = ref.read(profileFeedProvider(profileUri, false)); + feedState.whenData((feedState) { + final filteredUris = feedState.loadedPosts; + final postIndex = filteredUris.indexOf(postUri); + if (postIndex != -1) { + context.router.push( + StandaloneProfileFeedRoute( + profileUri: profileUri.toString(), + videosOnly: false, + initialPostIndex: postIndex, + ), + ); + } else { + context.router.push(StandalonePostRoute(postUri: postUri.toString())); + } + }); + } + + return buildProfileGridSlivers( + context: context, + ref: ref, + profileUri: profileUri, + videosOnly: false, + both: true, + onPostTap: onPostTap, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + // This widget is used by route pages to build slivers + // The actual rendering happens in ProfilePageTemplate via buildSlivers() + return const SizedBox.shrink(); + } +} diff --git a/lib/src/features/profile/ui/widgets/profile_grid_widget.dart b/lib/src/features/profile/ui/widgets/profile_grid_widget.dart index 503965b..ee59f0e 100644 --- a/lib/src/features/profile/ui/widgets/profile_grid_widget.dart +++ b/lib/src/features/profile/ui/widgets/profile_grid_widget.dart @@ -1,5 +1,4 @@ import 'package:atproto/core.dart'; -import 'package:auto_route/auto_route.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,191 +6,134 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:sparksocial/src/core/design_system/components/molecules/post_tile.dart'; import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; -import 'package:sparksocial/src/core/routing/app_router.dart'; import 'package:sparksocial/src/core/utils/label_utils.dart'; import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; -class ProfileGridWidget extends ConsumerStatefulWidget { - const ProfileGridWidget({ - required this.profileUri, - required this.videosOnly, - this.both = false, - super.key, - }); - final AtUri profileUri; - // When true, ignore videosOnly and show both images and videos - final bool both; - // When false and both is false: images only; when true and both is false: videos only - final bool videosOnly; - - @override - ConsumerState createState() => _ProfileGridWidgetState(); -} - -class _ProfileGridWidgetState extends ConsumerState { - late final ScrollController scrollController; - - @override - void initState() { - super.initState(); - scrollController = ScrollController(); - scrollController.addListener(_onScroll); - } - - @override - void dispose() { - scrollController.removeListener(_onScroll); - scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - // Trigger loading when user is within ~2 rows of the bottom (each row is roughly 200px at 9:16 aspect ratio) - if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 500) { - ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly).notifier).loadMore(); - } - } - - @override - Widget build(BuildContext context) { - // Watch once and filter locally based on flags - final feedState = ref.watch(profileFeedProvider(widget.profileUri, widget.videosOnly)); - - return feedState.when( - data: (state) { - // Filter posts in client depending on configuration - final filteredUris = () { - if (widget.both) return state.loadedPosts; - if (widget.videosOnly) { - return state.loadedPosts.where((u) => state.postTypes[u] ?? true).toList(); - } - // images only - return state.loadedPosts.where((u) => state.postTypes[u] == false).toList(); - }(); - - if (filteredUris.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - widget.both - ? FluentIcons.grid_24_regular - : (widget.videosOnly ? FluentIcons.video_24_regular : FluentIcons.image_24_regular), - size: 48, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - widget.both ? 'No posts yet' : (widget.videosOnly ? 'No videos yet' : 'No images yet'), - style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], - ), - ); +/// Builder function that creates slivers for the profile grid +List buildProfileGridSlivers({ + required BuildContext context, + required WidgetRef ref, + required AtUri profileUri, + required bool videosOnly, + required Function(BuildContext, WidgetRef, AtUri) onPostTap, + bool both = false, +}) { + final feedState = ref.watch(profileFeedProvider(profileUri, videosOnly)); + + return feedState.when( + data: (state) { + // Filter posts in client depending on configuration + final filteredUris = () { + if (both) return state.loadedPosts; + if (videosOnly) { + return state.loadedPosts.where((u) => state.postTypes[u] ?? true).toList(); } - - return CustomScrollView( - controller: scrollController, - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(5), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 5, - mainAxisSpacing: 5, - childAspectRatio: 9 / 16, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final postUri = filteredUris[index]; - final postView = state.postViews[postUri]; - final postSource = state.postSources[postUri]; - - if (postView == null) { - return const SizedBox.shrink(); - } - - return ProfileGridTile( - postView: postView, - postSource: postSource, - onTap: () => _onPostTapDynamic(postUri), - ); - }, - childCount: filteredUris.length, - ), + // images only + return state.loadedPosts.where((u) => state.postTypes[u] == false).toList(); + }(); + + if (filteredUris.isEmpty) { + return [ + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + both + ? FluentIcons.grid_24_regular + : (videosOnly ? FluentIcons.video_24_regular : FluentIcons.image_24_regular), + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + both ? 'No posts yet' : (videosOnly ? 'No videos yet' : 'No images yet'), + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], ), ), - ], - ); - }, - loading: () => Skeletonizer( - child: GridView.builder( + ), + ]; + } + + return [ + SliverPadding( padding: const EdgeInsets.all(5), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 5, + mainAxisSpacing: 5, + childAspectRatio: 9 / 16, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final postUri = filteredUris[index]; + final postView = state.postViews[postUri]; + final postSource = state.postSources[postUri]; + + if (postView == null) { + return const SizedBox.shrink(); + } + + return ProfileGridTile( + postView: postView, + postSource: postSource, + onTap: () => onPostTap(context, ref, postUri), + ); + }, + childCount: filteredUris.length, + ), + ), + ), + ]; + }, + loading: () => [ + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 5, mainAxisSpacing: 5, childAspectRatio: 9 / 16, ), - itemCount: 12, - itemBuilder: (context, index) => Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(15), + delegate: SliverChildBuilderDelegate( + (context, index) => Skeletonizer( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(15), + ), + ), ), + childCount: 12, ), ), ), - error: (error, stack) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(FluentIcons.error_circle_24_regular, size: 48), - const SizedBox(height: 16), - Text('Error loading posts: $error'), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly).notifier).refresh(), - child: const Text('Retry'), - ), - ], + ], + error: (error, stack) => [ + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(FluentIcons.error_circle_24_regular, size: 48), + const SizedBox(height: 16), + Text('Error loading posts: $error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.read(profileFeedProvider(profileUri, videosOnly).notifier).refresh(), + child: const Text('Retry'), + ), + ], + ), ), ), - ); - } - - void _onPostTap(AtUri postUri) { - final feedState = ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly)); - feedState.whenData((state) { - // Compute index based on the filtered list matching the standalone page behavior - final filteredUris = widget.videosOnly - ? state.loadedPosts.where((u) => state.postTypes[u] ?? true).toList() - : state.loadedPosts.where((u) => state.postTypes[u] == false).toList(); - final postIndex = filteredUris.indexOf(postUri); - if (postIndex != -1) { - context.router.push( - StandaloneProfileFeedRoute( - profileUri: widget.profileUri.toString(), - videosOnly: widget.videosOnly, - initialPostIndex: postIndex, - ), - ); - } else { - context.router.push(StandalonePostRoute(postUri: postUri.toString())); - } - }); - } - - void _onPostTapDynamic(AtUri postUri) { - if (widget.both) { - // Open standalone post directly for unified mode - context.router.push(StandalonePostRoute(postUri: postUri.toString())); - return; - } - _onPostTap(postUri); - } + ], + ); } class ProfileGridTile extends StatefulWidget { diff --git a/lib/src/features/profile/ui/widgets/profile_tab_base.dart b/lib/src/features/profile/ui/widgets/profile_tab_base.dart new file mode 100644 index 0000000..d1caed7 --- /dev/null +++ b/lib/src/features/profile/ui/widgets/profile_tab_base.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Base interface for profile tab widgets that build slivers +abstract class ProfileTabBase extends ConsumerWidget { + const ProfileTabBase({super.key}); + + /// Builds the slivers for this tab + List buildSlivers(BuildContext context, WidgetRef ref); +} -- 2.51.2