From 9a5681a91626f656c2feb2798d8b5d97ac36abd2 Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 5 Feb 2026 23:57:48 -0800 Subject: [PATCH] feat(ui): add responsive tablet layout support Introduce tablet-optimized layouts using a NavigationRail on larger screens and constrained content widths for improved readability. Changes: - Add ResponsiveUtils utility class with tablet detection (600dp breakpoint) and maxContentWidth constant (640px for optimal line length) - Replace bottom navigation with NavigationRail on tablets in MainShellScreen - Constrain feed content to maxContentWidth on tablets across all screens: - FeedPage, CommunityFeedScreen, ProfileScreen posts/comments - PostDetailScreen post card and comment threads - CommunitiesScreen placeholder content - Use wrapForTablet helper for consistent responsive wrapping Co-Authored-By: Claude Opus 4.5 --- .../community/community_feed_screen.dart | 16 ++- lib/screens/home/communities_screen.dart | 59 ++++++---- lib/screens/home/main_shell_screen.dart | 100 +++++++++++++++-- lib/screens/home/post_detail_screen.dart | 104 ++++++++++-------- lib/screens/home/profile_screen.dart | 31 +++++- lib/utils/responsive_utils.dart | 41 +++++++ lib/widgets/feed_page.dart | 17 ++- 7 files changed, 286 insertions(+), 82 deletions(-) create mode 100644 lib/utils/responsive_utils.dart diff --git a/lib/screens/community/community_feed_screen.dart b/lib/screens/community/community_feed_screen.dart index 1dd66a6..3ecd06d 100644 --- a/lib/screens/community/community_feed_screen.dart +++ b/lib/screens/community/community_feed_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../../constants/app_colors.dart'; +import '../../utils/responsive_utils.dart'; import '../../models/community.dart'; import '../../models/post.dart'; import '../../providers/auth_provider.dart'; @@ -751,7 +752,7 @@ class _CommunityFeedScreenState extends State { } final post = _posts[index]; - return RepaintBoundary( + final postCard = RepaintBoundary( key: ValueKey(post.post.uri), child: PostCard( post: post, @@ -759,6 +760,19 @@ class _CommunityFeedScreenState extends State { showHeader: true, ), ); + + // Constrain width on tablets for better readability + if (ResponsiveUtils.isTablet(context)) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ResponsiveUtils.maxContentWidth, + ), + child: postCard, + ), + ); + } + return postCard; }, childCount: _posts.length + (showLoadingSlot || !_hasMore ? 1 : 0), ), diff --git a/lib/screens/home/communities_screen.dart b/lib/screens/home/communities_screen.dart index 0d1833c..1cca27e 100644 --- a/lib/screens/home/communities_screen.dart +++ b/lib/screens/home/communities_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../constants/app_colors.dart'; +import '../../utils/responsive_utils.dart'; import '../../providers/auth_provider.dart'; import 'communities_admin_panel.dart'; @@ -47,31 +48,43 @@ class _CommunitiesPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.workspaces_outlined, size: 64, color: AppColors.primary), - SizedBox(height: 24), - Text( - 'Communities', - style: TextStyle( - fontSize: 28, - color: Colors.white, - fontWeight: FontWeight.bold, - ), + const content = Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.workspaces_outlined, size: 64, color: AppColors.primary), + SizedBox(height: 24), + Text( + 'Communities', + style: TextStyle( + fontSize: 28, + color: Colors.white, + fontWeight: FontWeight.bold, ), - SizedBox(height: 16), - Text( - 'Discover and join communities', - style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)), - textAlign: TextAlign.center, - ), - ], - ), + ), + SizedBox(height: 16), + Text( + 'Discover and join communities', + style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)), + textAlign: TextAlign.center, + ), + ], ), ); + + // Constrain width on tablets for better readability + if (ResponsiveUtils.isTablet(context)) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ResponsiveUtils.maxContentWidth, + ), + child: content, + ), + ); + } + + return Center(child: content); } } diff --git a/lib/screens/home/main_shell_screen.dart b/lib/screens/home/main_shell_screen.dart index dfe2f9f..67e7804 100644 --- a/lib/screens/home/main_shell_screen.dart +++ b/lib/screens/home/main_shell_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../constants/app_colors.dart'; +import '../../utils/responsive_utils.dart'; import '../../widgets/icons/bluesky_icons.dart'; import 'communities_screen.dart'; import 'create_post_screen.dart'; @@ -44,17 +45,96 @@ class _MainShellScreenState extends State { @override Widget build(BuildContext context) { + final isTablet = ResponsiveUtils.isTablet(context); + + final body = IndexedStack( + index: _selectedIndex, + children: [ + FeedScreen(key: _feedScreenKey, onSearchTap: _onCommunitiesTap), + const CommunitiesScreen(), + CreatePostScreen(onNavigateToFeed: _onNavigateToFeed), + const NotificationsScreen(), + const ProfileScreen(), + ], + ); + + // Tablet layout: NavigationRail on the left + if (isTablet) { + return Scaffold( + body: Row( + children: [ + // Wrap NavigationRail in a colored container that extends to + // status bar, preventing content from bleeding behind it + Container( + color: const Color(0xFF0B0F14), + child: SafeArea( + right: false, + bottom: false, + child: NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: _onItemTapped, + backgroundColor: const Color(0xFF0B0F14), + indicatorColor: AppColors.primary.withValues(alpha: 0.2), + labelType: NavigationRailLabelType.all, + destinations: [ + NavigationRailDestination( + icon: BlueSkyIcon.homeSimple( + color: const Color(0xFFB6C2D2).withValues(alpha: 0.6), + ), + selectedIcon: + BlueSkyIcon.homeSimple(color: AppColors.primary), + label: const Text('Home'), + ), + NavigationRailDestination( + icon: Icon( + Icons.workspaces_outlined, + color: const Color(0xFFB6C2D2).withValues(alpha: 0.6), + ), + selectedIcon: + const Icon(Icons.workspaces, color: AppColors.primary), + label: const Text('Communities'), + ), + NavigationRailDestination( + icon: BlueSkyIcon.plus( + color: const Color(0xFFB6C2D2).withValues(alpha: 0.6), + ), + selectedIcon: BlueSkyIcon.plus(color: AppColors.primary), + label: const Text('Create'), + ), + NavigationRailDestination( + icon: BlueSkyIcon.bellOutline( + color: const Color(0xFFB6C2D2).withValues(alpha: 0.6), + ), + selectedIcon: + BlueSkyIcon.bellFilled(color: AppColors.primary), + label: const Text('Notifications'), + ), + NavigationRailDestination( + icon: BlueSkyIcon.personSimple( + color: const Color(0xFFB6C2D2).withValues(alpha: 0.6), + ), + selectedIcon: + BlueSkyIcon.personSimple(color: AppColors.primary), + label: const Text('Me'), + ), + ], + ), + ), + ), + const VerticalDivider( + width: 1, + thickness: 1, + color: Color(0xFF1A2433), + ), + Expanded(child: body), + ], + ), + ); + } + + // Phone layout: Bottom navigation bar return Scaffold( - body: IndexedStack( - index: _selectedIndex, - children: [ - FeedScreen(key: _feedScreenKey, onSearchTap: _onCommunitiesTap), - const CommunitiesScreen(), - CreatePostScreen(onNavigateToFeed: _onNavigateToFeed), - const NotificationsScreen(), - const ProfileScreen(), - ], - ), + body: body, bottomNavigationBar: Container( decoration: const BoxDecoration( color: Color(0xFF0B0F14), diff --git a/lib/screens/home/post_detail_screen.dart b/lib/screens/home/post_detail_screen.dart index b227e52..3de8004 100644 --- a/lib/screens/home/post_detail_screen.dart +++ b/lib/screens/home/post_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../constants/app_colors.dart'; +import '../../utils/responsive_utils.dart'; import '../../models/comment.dart'; import '../../models/post.dart'; import '../../providers/auth_provider.dart'; @@ -811,68 +812,81 @@ class _PostDetailScreenState extends State { (context, index) { // Post card (index 0) if (index == 0) { - return Column( - children: [ - // Reuse PostCard (hide comment button in - // detail view) - // Use ValueListenableBuilder to only rebuild - // when time changes - _PostHeader( - post: widget.post, - currentTimeNotifier: - commentsProvider.currentTimeNotifier, - ), + return ResponsiveUtils.wrapForTablet( + context, + Column( + children: [ + // Reuse PostCard (hide comment button in + // detail view) + // Use ValueListenableBuilder to only rebuild + // when time changes + _PostHeader( + post: widget.post, + currentTimeNotifier: + commentsProvider.currentTimeNotifier, + ), - // Visual divider before comments section - Container( - margin: const EdgeInsets.symmetric( - vertical: 16, + // Visual divider before comments section + Container( + margin: const EdgeInsets.symmetric( + vertical: 16, + ), + height: 1, + color: AppColors.border, ), - height: 1, - color: AppColors.border, - ), - // Comments header with sort dropdown - CommentsHeader( - key: _commentsHeaderKey, - commentCount: widget.post.post.stats.commentCount, - currentSort: commentsProvider.sort, - onSortChanged: _onSortChanged, - ), - ], + // Comments header with sort dropdown + CommentsHeader( + key: _commentsHeaderKey, + commentCount: + widget.post.post.stats.commentCount, + currentSort: commentsProvider.sort, + onSortChanged: _onSortChanged, + ), + ], + ), ); } // Loading indicator or error at the end if (index == comments.length + 1) { if (isLoadingMore) { - return const InlineLoading(); + return ResponsiveUtils.wrapForTablet( + context, + const InlineLoading(), + ); } if (error != null) { - return InlineError( - message: getErrorMessage(error), - onRetry: () { - commentsProvider - ..clearError() - ..loadMoreComments(); - }, + return ResponsiveUtils.wrapForTablet( + context, + InlineError( + message: getErrorMessage(error), + onRetry: () { + commentsProvider + ..clearError() + ..loadMoreComments(); + }, + ), ); } } // Comment item - use existing CommentThread widget final comment = comments[index - 1]; - return _CommentItem( - comment: comment, - currentTimeNotifier: - commentsProvider.currentTimeNotifier, - onCommentTap: _openReplyToComment, - collapsedComments: - commentsProvider.collapsedComments, - onCollapseToggle: commentsProvider.toggleCollapsed, - onContinueThread: _onContinueThread, - onDelete: (uri) => - commentsProvider.deleteComment(commentUri: uri), + return ResponsiveUtils.wrapForTablet( + context, + _CommentItem( + comment: comment, + currentTimeNotifier: + commentsProvider.currentTimeNotifier, + onCommentTap: _openReplyToComment, + collapsedComments: + commentsProvider.collapsedComments, + onCollapseToggle: commentsProvider.toggleCollapsed, + onContinueThread: _onContinueThread, + onDelete: (uri) => + commentsProvider.deleteComment(commentUri: uri), + ), ); }, childCount: diff --git a/lib/screens/home/profile_screen.dart b/lib/screens/home/profile_screen.dart index fa92d3f..8401fe0 100644 --- a/lib/screens/home/profile_screen.dart +++ b/lib/screens/home/profile_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../../constants/app_colors.dart'; +import '../../utils/responsive_utils.dart'; import '../../models/comment.dart'; import '../../models/user_profile.dart'; import '../../providers/auth_provider.dart'; @@ -454,7 +455,20 @@ class _ProfileScreenState extends State { } final feedViewPost = postsState.posts[index]; - return PostCard(post: feedViewPost); + final postCard = PostCard(post: feedViewPost); + + // Constrain width on tablets for better readability + if (ResponsiveUtils.isTablet(context)) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ResponsiveUtils.maxContentWidth, + ), + child: postCard, + ), + ); + } + return postCard; }, childCount: postsState.posts.length + (showLoadingSlot ? 1 : 0)), ); } @@ -522,7 +536,20 @@ class _ProfileScreenState extends State { } final comment = commentsState.comments[index]; - return _ProfileCommentCard(comment: comment); + final commentCard = _ProfileCommentCard(comment: comment); + + // Constrain width on tablets for better readability + if (ResponsiveUtils.isTablet(context)) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ResponsiveUtils.maxContentWidth, + ), + child: commentCard, + ), + ); + } + return commentCard; }, childCount: commentsState.comments.length + (showLoadingSlot ? 1 : 0)), ); } diff --git a/lib/utils/responsive_utils.dart b/lib/utils/responsive_utils.dart new file mode 100644 index 0000000..e2d7776 --- /dev/null +++ b/lib/utils/responsive_utils.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/// Utility class for responsive layout detection and sizing. +/// +/// Provides tablet detection and content width constraints for +/// adapting layouts between phone and tablet form factors. +class ResponsiveUtils { + /// Tablets have shortestSide >= 600dp (Material Design guidelines) + static const double tabletBreakpoint = 600; + + /// Maximum content width for readability on large screens. + /// + /// 640px provides an optimal line length of ~70-80 characters for body text, + /// which research shows maximizes reading comprehension and comfort. + /// This value also aligns with common content-width patterns in web design. + static const double maxContentWidth = 640; + + /// Returns true if device is a tablet (based on shortest side). + /// + /// Uses shortestSide to handle both portrait and landscape orientations + /// consistently - a tablet is still a tablet regardless of rotation. + static bool isTablet(BuildContext context) { + return MediaQuery.sizeOf(context).shortestSide >= tabletBreakpoint; + } + + /// Wraps [child] with centered max-width constraints on tablets. + /// + /// On phones, returns the child unchanged. + /// On tablets, centers the child within [maxContentWidth] constraints. + static Widget wrapForTablet(BuildContext context, Widget child) { + if (!isTablet(context)) { + return child; + } + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: maxContentWidth), + child: child, + ), + ); + } +} diff --git a/lib/widgets/feed_page.dart b/lib/widgets/feed_page.dart index 58f1e20..4486dee 100644 --- a/lib/widgets/feed_page.dart +++ b/lib/widgets/feed_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../constants/app_colors.dart'; import '../models/post.dart'; import '../providers/multi_feed_provider.dart'; +import '../utils/responsive_utils.dart'; import 'post_card.dart'; /// FeedPage widget for rendering a single feed's content @@ -213,7 +214,7 @@ class _FeedPageState extends State // ValueKey on RepaintBoundary ensures Flutter correctly identifies // and reuses the entire isolated subtree during list updates, // preserving both identity and paint optimization. - return RepaintBoundary( + final postCard = RepaintBoundary( key: ValueKey(post.post.uri), child: Semantics( label: @@ -224,6 +225,20 @@ class _FeedPageState extends State child: PostCard(post: post, currentTime: widget.currentTime), ), ); + + // Constrain width on tablets for better readability + if (ResponsiveUtils.isTablet(context)) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ResponsiveUtils.maxContentWidth, + ), + child: postCard, + ), + ); + } + + return postCard; }, childCount: widget.posts.length + (_shouldShowFooter ? 1 : 0), // findChildIndexCallback enables Flutter to track items by key -- 2.51.2