mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

feat: responsive feed layout and post footer

+186 -118
+32 -32
lib/core/router/app_router.dart
··· 120 120 path: '/saved', 121 121 builder: (context, state) => SavedPostsScreen(accountDid: context.read<String>()), 122 122 ), 123 - GoRoute( 124 - path: '/messages', 125 - parentNavigatorKey: _rootNavigatorKey, 126 - builder: (context, state) => const ConvoListScreen(), 127 - routes: [ 128 - GoRoute( 129 - path: ':id', 130 - builder: (context, state) { 131 - final convoId = state.pathParameters['id']!; 132 - final args = state.extra as MessageThreadRouteArgs?; 133 - return BlocProvider( 134 - create: (_) => MessageBloc( 135 - convoRepository: context.read<ConvoRepository>(), 136 - currentUserDid: context.read<String>(), 137 - ), 138 - child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 139 - ); 140 - }, 141 - ), 142 - ], 143 - ), 144 - GoRoute( 145 - path: '/settings', 146 - parentNavigatorKey: _rootNavigatorKey, 147 - builder: (context, state) => const SettingsScreen(), 148 - routes: [ 149 - GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 150 - GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 151 - GoRoute(path: 'devtools', builder: (context, state) => const DevToolsScreen()), 152 - ], 153 - ), 154 123 StatefulShellRoute.indexedStack( 155 124 builder: (context, state, navigationShell) { 156 125 if (!context.read<AuthBloc>().state.isAuthenticated) { ··· 187 156 GoRoute( 188 157 path: '/', 189 158 builder: (context, state) => const HomeFeedScreen(), 190 - routes: [GoRoute(path: 'feeds', builder: (context, state) => const FeedManagementScreen())], 159 + routes: [ 160 + GoRoute(path: 'feeds', builder: (context, state) => const FeedManagementScreen()), 161 + GoRoute( 162 + path: 'messages', 163 + builder: (context, state) => const ConvoListScreen(), 164 + routes: [ 165 + GoRoute( 166 + path: ':id', 167 + builder: (context, state) { 168 + final convoId = state.pathParameters['id']!; 169 + final args = state.extra as MessageThreadRouteArgs?; 170 + return BlocProvider( 171 + create: (_) => MessageBloc( 172 + convoRepository: context.read<ConvoRepository>(), 173 + currentUserDid: context.read<String>(), 174 + ), 175 + child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 176 + ); 177 + }, 178 + ), 179 + ], 180 + ), 181 + GoRoute( 182 + path: 'settings', 183 + builder: (context, state) => const SettingsScreen(), 184 + routes: [ 185 + GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 186 + GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 187 + GoRoute(path: 'devtools', builder: (context, state) => const DevToolsScreen()), 188 + ], 189 + ), 190 + ], 191 191 ), 192 192 ], 193 193 ),
+28
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 49 49 Widget _buildGrid(BuildContext context) { 50 50 final width = MediaQuery.of(context).size.width; 51 51 final columns = feedColumnCount(width); 52 + if (columns == 1) { 53 + return _buildSingleColumnGrid(context); 54 + } 52 55 final tileWidth = (width - ((columns - 1) * _gridSpacing)) / columns; 53 56 54 57 return RefreshIndicator( ··· 64 67 mainAxisSpacing: _gridSpacing, 65 68 // Grid cards have a square media region plus fixed author/body/footer chrome. 66 69 mainAxisExtent: tileWidth + _gridCardChromeHeight, 70 + ), 71 + ), 72 + if (isLoadingMore) 73 + const SliverToBoxAdapter( 74 + child: Center( 75 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 76 + ), 77 + ), 78 + ], 79 + ), 80 + ); 81 + } 82 + 83 + Widget _buildSingleColumnGrid(BuildContext context) { 84 + return RefreshIndicator( 85 + onRefresh: onRefresh, 86 + child: CustomScrollView( 87 + controller: scrollController, 88 + slivers: [ 89 + SliverPadding( 90 + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), 91 + sliver: SliverList.separated( 92 + itemCount: itemCount, 93 + itemBuilder: gridItemBuilder, 94 + separatorBuilder: (_, _) => const SizedBox(height: 12), 67 95 ), 68 96 ), 69 97 if (isLoadingMore)
+84 -76
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 69 69 const iconSize = 18.0; 70 70 const actionPadding = 4.0; 71 71 72 - return Container( 73 - decoration: BoxDecoration( 74 - border: Border(top: BorderSide(color: colorScheme.outlineVariant)), 75 - ), 76 - padding: const EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 8), 77 - child: Row( 78 - children: [ 79 - _FooterAction( 80 - icon: Icons.chat_bubble_outline, 81 - activeIcon: Icons.chat_bubble, 82 - isActive: false, 83 - isLoading: false, 84 - count: replyCount, 85 - onTap: onReply, 86 - color: colorScheme.onSurfaceVariant, 87 - iconSize: iconSize, 88 - padding: actionPadding, 89 - showCount: showCounts, 90 - ), 91 - const SizedBox(width: actionSpacing), 92 - _FooterAction( 93 - icon: Icons.repeat, 94 - activeIcon: Icons.repeat, 95 - isActive: isReposted, 96 - isLoading: isLoadingRepost, 97 - count: repostCount, 98 - onTap: onRepost, 99 - color: colorScheme.onSurfaceVariant, 100 - activeColor: Colors.green, 101 - iconSize: iconSize, 102 - padding: actionPadding, 103 - showCount: showCounts, 104 - ), 105 - const SizedBox(width: actionSpacing), 106 - _FooterAction( 107 - icon: Icons.favorite_outline, 108 - activeIcon: Icons.favorite, 109 - isActive: isLiked, 110 - isLoading: isLoadingLike, 111 - count: likeCount, 112 - onTap: onLike, 113 - color: colorScheme.onSurfaceVariant, 114 - activeColor: Colors.pink, 115 - iconSize: iconSize, 116 - padding: actionPadding, 117 - showCount: showCounts, 118 - ), 119 - const SizedBox(width: actionSpacing), 120 - _FooterAction( 121 - icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, 122 - activeIcon: Icons.bookmark, 123 - isActive: isSaved, 124 - isLoading: false, 125 - count: saveCount, 126 - onTap: onSave != null ? () => _showSaveOptions(context) : null, 127 - onLongPress: onLongPressSave, 128 - color: colorScheme.onSurfaceVariant, 129 - activeColor: saveActiveColor, 130 - iconSize: iconSize, 131 - padding: actionPadding, 132 - showCount: showCounts, 72 + return LayoutBuilder( 73 + builder: (context, constraints) { 74 + final canShowCounts = showCounts && constraints.maxWidth >= 240; 75 + 76 + return Container( 77 + decoration: BoxDecoration( 78 + border: Border(top: BorderSide(color: colorScheme.outlineVariant)), 133 79 ), 134 - const SizedBox(width: actionSpacing), 135 - Expanded( 136 - child: Align( 137 - alignment: Alignment.centerRight, 138 - child: Text( 139 - timestamp, 140 - maxLines: 1, 141 - overflow: TextOverflow.ellipsis, 142 - softWrap: false, 143 - style: Theme.of( 144 - context, 145 - ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10, letterSpacing: 1.0), 80 + padding: const EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 8), 81 + child: Row( 82 + children: [ 83 + _FooterAction( 84 + icon: Icons.chat_bubble_outline, 85 + activeIcon: Icons.chat_bubble, 86 + isActive: false, 87 + isLoading: false, 88 + count: replyCount, 89 + onTap: onReply, 90 + color: colorScheme.onSurfaceVariant, 91 + iconSize: iconSize, 92 + padding: actionPadding, 93 + showCount: canShowCounts, 146 94 ), 147 - ), 95 + const SizedBox(width: actionSpacing), 96 + _FooterAction( 97 + icon: Icons.repeat, 98 + activeIcon: Icons.repeat, 99 + isActive: isReposted, 100 + isLoading: isLoadingRepost, 101 + count: repostCount, 102 + onTap: onRepost, 103 + color: colorScheme.onSurfaceVariant, 104 + activeColor: Colors.green, 105 + iconSize: iconSize, 106 + padding: actionPadding, 107 + showCount: canShowCounts, 108 + ), 109 + const SizedBox(width: actionSpacing), 110 + _FooterAction( 111 + icon: Icons.favorite_outline, 112 + activeIcon: Icons.favorite, 113 + isActive: isLiked, 114 + isLoading: isLoadingLike, 115 + count: likeCount, 116 + onTap: onLike, 117 + color: colorScheme.onSurfaceVariant, 118 + activeColor: Colors.pink, 119 + iconSize: iconSize, 120 + padding: actionPadding, 121 + showCount: canShowCounts, 122 + ), 123 + const SizedBox(width: actionSpacing), 124 + _FooterAction( 125 + icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, 126 + activeIcon: Icons.bookmark, 127 + isActive: isSaved, 128 + isLoading: false, 129 + count: saveCount, 130 + onTap: onSave != null ? () => _showSaveOptions(context) : null, 131 + onLongPress: onLongPressSave, 132 + color: colorScheme.onSurfaceVariant, 133 + activeColor: saveActiveColor, 134 + iconSize: iconSize, 135 + padding: actionPadding, 136 + showCount: canShowCounts, 137 + ), 138 + const SizedBox(width: actionSpacing), 139 + Expanded( 140 + child: Align( 141 + alignment: Alignment.centerRight, 142 + child: Text( 143 + timestamp, 144 + maxLines: 1, 145 + overflow: TextOverflow.ellipsis, 146 + softWrap: false, 147 + style: Theme.of(context).textTheme.bodySmall?.copyWith( 148 + color: colorScheme.onSurfaceVariant, 149 + fontSize: 10, 150 + letterSpacing: 1.0, 151 + ), 152 + ), 153 + ), 154 + ), 155 + ], 148 156 ), 149 - ], 150 - ), 157 + ); 158 + }, 151 159 ); 152 160 } 153 161
+1 -1
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 146 146 onLongPressSave: () => unawaited(_onToggleSave(context)), 147 147 onCloudSave: () => unawaited(_onCloudSave(context)), 148 148 onCloudUnsave: () => unawaited(_onCloudUnsave(context)), 149 - showCounts: variant == PostCardVariant.linear, 149 + showCounts: true, 150 150 ); 151 151 }, 152 152 );
+13 -9
test/features/feed/presentation/home_feed_screen_test.dart
··· 75 75 76 76 group('FeedLayoutView — grid architecture', () { 77 77 testWidgets('shows SliverGrid when architecture is grid', (tester) async { 78 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid)); 78 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); 79 79 expect(find.byType(SliverGrid), findsOneWidget); 80 80 expect(find.byType(CustomScrollView), findsOneWidget); 81 81 }); ··· 89 89 testWidgets('uses 1 column at width < 600', (tester) async { 90 90 await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 400)); 91 91 92 - final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 93 - final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 94 - expect(delegate.crossAxisCount, 1); 92 + expect(find.byType(SliverGrid), findsNothing); 93 + expect(find.byType(SliverList), findsOneWidget); 94 + }); 95 + 96 + testWidgets('uses tighter single-column padding at phone widths', (tester) async { 97 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 400)); 98 + 99 + final padding = tester.widget<SliverPadding>(find.byType(SliverPadding)); 100 + expect(padding.padding, const EdgeInsets.fromLTRB(12, 8, 12, 12)); 95 101 }); 96 102 97 103 testWidgets('uses 2 columns at width 600–839', (tester) async { ··· 119 125 }); 120 126 121 127 testWidgets('allocates extra height beyond the square media region', (tester) async { 122 - const screenWidth = 400.0; 123 - 124 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: screenWidth)); 128 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); 125 129 126 130 final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 127 131 final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 128 - const tileWidth = screenWidth; 132 + const tileWidth = (720.0 - 1.0) / 2; 129 133 130 134 expect(delegate.mainAxisExtent, isNotNull); 131 135 expect(delegate.mainAxisExtent!, greaterThan(tileWidth + 100)); ··· 168 172 169 173 await tester.pumpWidget( 170 174 MediaQuery( 171 - data: const MediaQueryData(size: Size(400, 800)), 175 + data: const MediaQueryData(size: Size(720, 800)), 172 176 child: MaterialApp( 173 177 home: Scaffold( 174 178 body: BlocProvider<SettingsCubit>.value(