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

fix: unauth vs. auth "home" feed

* add toggle for feed selector

* correct pull to refresh position

Changed files
+333 -319
lib
src
features
composer
presentation
feeds
presentation
profile
infrastructure
test
src
features
profile
infrastructure
+1 -2
lib/src/features/composer/presentation/widgets/global_compose_fab.dart
··· 17 17 18 18 return FloatingActionButton.extended( 19 19 onPressed: () => context.push(AppRoutes.compose), 20 - icon: const Icon(Icons.edit_outlined), 21 - label: const Text('Post'), 20 + label: const Icon(Icons.edit_outlined), 22 21 backgroundColor: theme.colorScheme.primary, 23 22 foregroundColor: theme.colorScheme.onPrimary, 24 23 elevation: 6,
+53 -8
lib/src/features/feeds/presentation/screens/feed_screen.dart
··· 4 4 import 'package:lazurite/src/core/widgets/error_view.dart'; 5 5 import 'package:lazurite/src/core/widgets/loading_view.dart'; 6 6 import 'package:lazurite/src/core/widgets/pull_to_refresh_wrapper.dart'; 7 + import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 8 + import 'package:lazurite/src/features/auth/domain/auth_state.dart'; 7 9 import 'package:lazurite/src/features/feeds/application/feed_content_cleanup_controller.dart'; 8 10 import 'package:lazurite/src/features/feeds/application/feed_content_notifier.dart'; 9 11 import 'package:lazurite/src/features/feeds/application/feed_providers.dart'; ··· 25 27 class _FeedScreenState extends ConsumerState<FeedScreen> { 26 28 final ScrollController _scrollController = ScrollController(); 27 29 String? _lastRequestedFeed; 30 + bool _isFeedSelectorExpanded = true; 28 31 29 32 @override 30 33 void initState() { ··· 49 52 Widget build(BuildContext context) { 50 53 ref.watch(feedContentCleanupControllerProvider); 51 54 final activeFeedUri = ref.watch(activeFeedProvider); 55 + final authState = ref.watch(authProvider); 56 + final isAuthenticated = authState is AuthStateAuthenticated; 57 + 58 + // For authenticated users, wait for pinned feeds to load before showing content 59 + // This prevents briefly showing the wrong feed during initialization 60 + if (isAuthenticated) { 61 + final pinnedFeedsAsync = ref.watch(pinnedFeedsProvider); 62 + if (pinnedFeedsAsync.isLoading) { 63 + return const Scaffold(body: LoadingView(key: ValueKey('pinned-feeds-loading'))); 64 + } 65 + } 66 + 52 67 final feedContentState = ref.watch(feedContentProvider(activeFeedUri)); 53 68 _ensureFeedLoaded(activeFeedUri); 54 69 ··· 66 81 physics: const AlwaysScrollableScrollPhysics(), 67 82 slivers: [ 68 83 SliverAppBar( 69 - title: Text('Lazurite', style: Theme.of(context).textTheme.displaySmall), 84 + title: Row( 85 + mainAxisSize: MainAxisSize.min, 86 + children: [ 87 + Text('Lazurite', style: Theme.of(context).textTheme.displaySmall), 88 + if (isAuthenticated) 89 + ScaleButton( 90 + child: IconButton( 91 + icon: Icon( 92 + _isFeedSelectorExpanded 93 + ? Icons.keyboard_arrow_up 94 + : Icons.keyboard_arrow_down, 95 + size: 20, 96 + ), 97 + onPressed: () { 98 + setState(() { 99 + _isFeedSelectorExpanded = !_isFeedSelectorExpanded; 100 + }); 101 + }, 102 + tooltip: _isFeedSelectorExpanded ? 'Hide feeds' : 'Show feeds', 103 + ), 104 + ), 105 + ], 106 + ), 70 107 floating: true, 71 108 snap: true, 72 - bottom: const PreferredSize( 73 - preferredSize: Size.fromHeight(56), 74 - child: Padding( 75 - padding: EdgeInsets.only(bottom: 8.0), 76 - child: FeedSelectorTab(), 77 - ), 78 - ), 109 + bottom: isAuthenticated 110 + ? PreferredSize( 111 + preferredSize: Size.fromHeight(_isFeedSelectorExpanded ? 56 : 0), 112 + child: AnimatedSize( 113 + duration: const Duration(milliseconds: 200), 114 + curve: Curves.easeInOut, 115 + child: _isFeedSelectorExpanded 116 + ? const Padding( 117 + padding: EdgeInsets.only(bottom: 8.0), 118 + child: FeedSelectorTab(), 119 + ) 120 + : const SizedBox.shrink(), 121 + ), 122 + ) 123 + : null, 79 124 ), 80 125 if (items.isEmpty) 81 126 const SliverFillRemaining(
+74 -82
lib/src/features/feeds/presentation/widgets/feed_selector_tab.dart
··· 11 11 12 12 /// Tab widget for selecting between pinned feeds. 13 13 /// 14 - /// For authenticated users, shows their pinned feeds plus a "Manage Feeds" button. 15 - /// For unauthenticated users, shows only the "Discover" feed chip. 14 + /// For authenticated users, shows their pinned feeds with a persistent 15 + /// "Manage Feeds" button. 16 + /// For unauthenticated users, hides the feed selector entirely. 16 17 class FeedSelectorTab extends ConsumerWidget { 17 18 const FeedSelectorTab({super.key}); 18 19 ··· 22 23 final isAuthenticated = authState is AuthStateAuthenticated; 23 24 24 25 if (!isAuthenticated) { 25 - return _buildUnauthenticatedView(context, ref); 26 + return _buildUnauthenticatedView(); 26 27 } 27 28 28 29 return _buildAuthenticatedView(context, ref); 29 30 } 30 31 31 32 /// Builds the feed selector for unauthenticated users. 32 - /// Shows only the "Discover" chip with no management options. 33 - Widget _buildUnauthenticatedView(BuildContext context, WidgetRef ref) { 34 - return SizedBox( 35 - height: 48, 36 - child: Padding( 37 - padding: const EdgeInsets.symmetric(horizontal: 16), 38 - child: Row( 39 - children: [ 40 - FilterChip( 41 - showCheckmark: false, 42 - label: const Text('Discover'), 43 - selected: true, 44 - onSelected: (_) {}, 45 - ), 46 - ], 47 - ), 48 - ), 49 - ); 33 + /// Returns an empty widget since unauthenticated users don't need feed selection. 34 + Widget _buildUnauthenticatedView() { 35 + return const SizedBox.shrink(); 50 36 } 51 37 52 38 /// Builds the feed selector for authenticated users. 53 - /// Shows pinned feeds and a "Manage Feeds" button. 39 + /// Shows pinned feeds with a persistent "Manage Feeds" button. 54 40 Widget _buildAuthenticatedView(BuildContext context, WidgetRef ref) { 55 41 final pinnedFeedsAsync = ref.watch(pinnedFeedsProvider); 56 42 final activeFeedUri = ref.watch(activeFeedProvider); ··· 73 59 74 60 return SizedBox( 75 61 height: 48, 76 - child: ListView.separated( 77 - padding: const EdgeInsets.symmetric(horizontal: 16), 78 - scrollDirection: Axis.horizontal, 79 - itemCount: displayFeeds.length + 1, 80 - separatorBuilder: (context, index) => const SizedBox(width: 8), 81 - itemBuilder: (context, index) { 82 - if (index == displayFeeds.length) { 83 - return Stack( 84 - alignment: Alignment.center, 85 - children: [ 86 - Tooltip( 87 - message: 'Manage Feeds', 88 - child: ScaleButton( 89 - child: IconButton( 90 - icon: const Icon(Icons.tune), 91 - onPressed: () { 92 - context.push(AppRoutes.feeds); 93 - }, 94 - ), 95 - ), 96 - ), 97 - Consumer( 98 - builder: (context, ref, child) { 99 - final hasPending = 100 - ref.watch(hasPendingSyncProvider).asData?.value ?? false; 101 - if (hasPending) { 102 - return Positioned( 103 - right: 8, 104 - top: 8, 105 - child: Container( 106 - width: 8, 107 - height: 8, 108 - decoration: const BoxDecoration( 109 - color: Colors.blue, 110 - shape: BoxShape.circle, 111 - ), 112 - ), 113 - ); 62 + child: Row( 63 + children: [ 64 + // Scrollable feed chips 65 + Expanded( 66 + child: ListView.separated( 67 + padding: const EdgeInsets.only(left: 16), 68 + scrollDirection: Axis.horizontal, 69 + itemCount: displayFeeds.length, 70 + separatorBuilder: (context, index) => const SizedBox(width: 8), 71 + itemBuilder: (context, index) { 72 + final feed = displayFeeds[index]; 73 + final isActive = feed.uri == activeFeedUri; 74 + 75 + return FilterChip( 76 + showCheckmark: false, 77 + label: Text(feed.displayName), 78 + avatar: feed.avatar != null 79 + ? CircleAvatar(backgroundImage: NetworkImage(feed.avatar!)) 80 + : null, 81 + selected: isActive, 82 + onSelected: (selected) { 83 + if (selected) { 84 + ref.read(activeFeedProvider.notifier).switchFeed(feed.uri); 114 85 } 115 - return const SizedBox.shrink(); 116 86 }, 117 - ), 118 - ], 119 - ); 120 - } 87 + ); 88 + }, 89 + ), 90 + ), 121 91 122 - final feed = displayFeeds[index]; 123 - final isActive = feed.uri == activeFeedUri; 124 - 125 - return FilterChip( 126 - showCheckmark: false, 127 - label: Text(feed.displayName), 128 - avatar: feed.avatar != null 129 - ? CircleAvatar(backgroundImage: NetworkImage(feed.avatar!)) 130 - : null, 131 - selected: isActive, 132 - onSelected: (selected) { 133 - if (selected) { 134 - ref.read(activeFeedProvider.notifier).switchFeed(feed.uri); 135 - } 136 - }, 137 - ); 138 - }, 92 + // Persistent manage feeds button 93 + Stack( 94 + alignment: Alignment.center, 95 + children: [ 96 + Tooltip( 97 + message: 'Manage Feeds', 98 + child: ScaleButton( 99 + child: IconButton( 100 + icon: const Icon(Icons.tune), 101 + onPressed: () { 102 + context.push(AppRoutes.feeds); 103 + }, 104 + ), 105 + ), 106 + ), 107 + Consumer( 108 + builder: (context, ref, child) { 109 + final hasPending = ref.watch(hasPendingSyncProvider).asData?.value ?? false; 110 + if (hasPending) { 111 + return Positioned( 112 + right: 8, 113 + top: 8, 114 + child: Container( 115 + width: 8, 116 + height: 8, 117 + decoration: const BoxDecoration( 118 + color: Colors.blue, 119 + shape: BoxShape.circle, 120 + ), 121 + ), 122 + ); 123 + } 124 + return const SizedBox.shrink(); 125 + }, 126 + ), 127 + ], 128 + ), 129 + const SizedBox(width: 8), 130 + ], 139 131 ), 140 132 ); 141 133 },
+99 -112
lib/src/features/profile/presentation/profile_screen.dart
··· 173 173 ], 174 174 ), 175 175 ), 176 - body: NestedScrollView( 177 - headerSliverBuilder: (context, innerBoxIsScrolled) { 178 - return [ 179 - SliverToBoxAdapter( 180 - child: ProfileHeader( 181 - profile: profile, 182 - onFollowersPressed: () { 183 - final encodedDid = Uri.encodeComponent(profile.did); 184 - context.push('/profile/followers/$encodedDid'); 185 - }, 186 - onFollowingPressed: () { 187 - final encodedDid = Uri.encodeComponent(profile.did); 188 - context.push('/profile/following/$encodedDid'); 189 - }, 190 - followButton: widget.isCurrentUser ? null : _followButton(profile), 191 - ), 192 - ), 193 - ]; 176 + body: RefreshIndicator( 177 + onRefresh: () async { 178 + await ref.read(profileProvider(widget.did).notifier).refresh(); 179 + await ref.read(authorFeedProvider(widget.did).notifier).refresh(); 194 180 }, 195 - body: feedAsync.when( 196 - data: (items) { 197 - return TabBarView( 198 - controller: _tabController, 199 - children: [ 200 - _PostsTab( 201 - items: items 202 - .where( 203 - (item) => 204 - !item.isReply && 205 - (profile.pinnedPostUri == null || 206 - item.uri != profile.pinnedPostUri), 207 - ) 208 - .toList(), 209 - pinnedPostUri: profile.pinnedPostUri, 210 - hasMore: hasMore, 211 - isLoading: false, 212 - onLoadMore: () => 213 - ref.read(authorFeedProvider(widget.did).notifier).loadMore(), 214 - onRefresh: () async { 215 - await ref.read(profileProvider(widget.did).notifier).refresh(); 216 - await ref.read(authorFeedProvider(widget.did).notifier).refresh(); 217 - }, 218 - ), 219 - RepliesTab( 220 - items: items, 221 - hasMore: hasMore, 222 - isLoading: false, 223 - onLoadMore: () => 224 - ref.read(authorFeedProvider(widget.did).notifier).loadMore(), 225 - onRefresh: () async { 226 - await ref.read(profileProvider(widget.did).notifier).refresh(); 227 - await ref.read(authorFeedProvider(widget.did).notifier).refresh(); 181 + child: NestedScrollView( 182 + headerSliverBuilder: (context, innerBoxIsScrolled) { 183 + return [ 184 + SliverToBoxAdapter( 185 + child: ProfileHeader( 186 + profile: profile, 187 + onFollowersPressed: () { 188 + final encodedDid = Uri.encodeComponent(profile.did); 189 + context.push('/profile/followers/$encodedDid'); 228 190 }, 229 - ), 230 - MediaTab( 231 - items: items, 232 - hasMore: hasMore, 233 - isLoading: false, 234 - onLoadMore: () => 235 - ref.read(authorFeedProvider(widget.did).notifier).loadMore(), 236 - onRefresh: () async { 237 - await ref.read(profileProvider(widget.did).notifier).refresh(); 238 - await ref.read(authorFeedProvider(widget.did).notifier).refresh(); 191 + onFollowingPressed: () { 192 + final encodedDid = Uri.encodeComponent(profile.did); 193 + context.push('/profile/following/$encodedDid'); 239 194 }, 195 + followButton: widget.isCurrentUser ? null : _followButton(profile), 240 196 ), 241 - ], 242 - ); 197 + ), 198 + ]; 243 199 }, 244 - loading: () => const LoadingView(), 245 - error: (err, _) => Center(child: Text('Error loading posts: $err')), 200 + body: feedAsync.when( 201 + data: (items) { 202 + return TabBarView( 203 + controller: _tabController, 204 + children: [ 205 + _PostsTab( 206 + items: items 207 + .where( 208 + (item) => 209 + !item.isReply && 210 + (profile.pinnedPostUri == null || 211 + item.uri != profile.pinnedPostUri), 212 + ) 213 + .toList(), 214 + pinnedPostUri: profile.pinnedPostUri, 215 + hasMore: hasMore, 216 + isLoading: false, 217 + onLoadMore: () => 218 + ref.read(authorFeedProvider(widget.did).notifier).loadMore(), 219 + ), 220 + RepliesTab( 221 + items: items, 222 + hasMore: hasMore, 223 + isLoading: false, 224 + onLoadMore: () => 225 + ref.read(authorFeedProvider(widget.did).notifier).loadMore(), 226 + ), 227 + MediaTab( 228 + items: items, 229 + hasMore: hasMore, 230 + isLoading: false, 231 + onLoadMore: () => 232 + ref.read(authorFeedProvider(widget.did).notifier).loadMore(), 233 + ), 234 + ], 235 + ); 236 + }, 237 + loading: () => const LoadingView(), 238 + error: (err, _) => Center(child: Text('Error loading posts: $err')), 239 + ), 246 240 ), 247 241 ), 248 242 ); ··· 271 265 required this.hasMore, 272 266 required this.isLoading, 273 267 required this.onLoadMore, 274 - required this.onRefresh, 275 268 }); 276 269 277 270 final List<FeedItem> items; ··· 279 272 final bool hasMore; 280 273 final bool isLoading; 281 274 final VoidCallback onLoadMore; 282 - final Future<void> Function() onRefresh; 283 275 284 276 @override 285 277 State<_PostsTab> createState() => _PostsTabState(); ··· 297 289 return const Center(child: Text('No posts yet')); 298 290 } 299 291 300 - return RefreshIndicator( 301 - onRefresh: widget.onRefresh, 302 - child: ListView.builder( 303 - physics: const AlwaysScrollableScrollPhysics(), 304 - itemCount: 305 - widget.items.length + 306 - (widget.hasMore ? 1 : 0) + 307 - (widget.pinnedPostUri != null ? 1 : 0), 308 - itemBuilder: (context, index) { 309 - int itemIndex = index; 292 + return ListView.builder( 293 + physics: const AlwaysScrollableScrollPhysics(), 294 + itemCount: 295 + widget.items.length + (widget.hasMore ? 1 : 0) + (widget.pinnedPostUri != null ? 1 : 0), 296 + itemBuilder: (context, index) { 297 + int itemIndex = index; 310 298 311 - if (widget.pinnedPostUri != null) { 312 - if (index == 0) { 313 - return PinnedPostCard(widget.pinnedPostUri!); 314 - } 315 - itemIndex--; 299 + if (widget.pinnedPostUri != null) { 300 + if (index == 0) { 301 + return PinnedPostCard(widget.pinnedPostUri!); 316 302 } 303 + itemIndex--; 304 + } 317 305 318 - if (itemIndex >= widget.items.length) { 319 - widget.onLoadMore(); 320 - return const Padding( 321 - padding: EdgeInsets.all(16), 322 - child: Center(child: CircularProgressIndicator()), 323 - ); 324 - } 306 + if (itemIndex >= widget.items.length) { 307 + widget.onLoadMore(); 308 + return const Padding( 309 + padding: EdgeInsets.all(16), 310 + child: Center(child: CircularProgressIndicator()), 311 + ); 312 + } 325 313 326 - final item = widget.items[itemIndex]; 327 - return FeedPostCard( 328 - uri: item.uri, 329 - authorDid: item.authorDid, 330 - authorHandle: item.authorHandle, 331 - authorDisplayName: item.authorDisplayName, 332 - authorAvatar: item.authorAvatar, 333 - text: item.text, 334 - indexedAt: item.indexedAt, 335 - replyCount: item.replyCount, 336 - repostCount: item.repostCount, 337 - likeCount: item.likeCount, 338 - onTap: () { 339 - final encodedUri = Uri.encodeComponent(item.uri); 340 - GoRouter.of(context).push('/home/t/$encodedUri'); 341 - }, 342 - onAvatarTap: () { 343 - final encodedDid = Uri.encodeComponent(item.authorDid); 344 - GoRouter.of(context).push('/home/u/$encodedDid'); 345 - }, 346 - ); 347 - }, 348 - ), 314 + final item = widget.items[itemIndex]; 315 + return FeedPostCard( 316 + uri: item.uri, 317 + authorDid: item.authorDid, 318 + authorHandle: item.authorHandle, 319 + authorDisplayName: item.authorDisplayName, 320 + authorAvatar: item.authorAvatar, 321 + text: item.text, 322 + indexedAt: item.indexedAt, 323 + replyCount: item.replyCount, 324 + repostCount: item.repostCount, 325 + likeCount: item.likeCount, 326 + onTap: () { 327 + final encodedUri = Uri.encodeComponent(item.uri); 328 + GoRouter.of(context).push('/home/t/$encodedUri'); 329 + }, 330 + onAvatarTap: () { 331 + final encodedDid = Uri.encodeComponent(item.authorDid); 332 + GoRouter.of(context).push('/home/u/$encodedDid'); 333 + }, 334 + ); 335 + }, 349 336 ); 350 337 } 351 338 }
+33 -38
lib/src/features/profile/presentation/widgets/media_tab.dart
··· 10 10 required this.hasMore, 11 11 required this.isLoading, 12 12 required this.onLoadMore, 13 - required this.onRefresh, 14 13 super.key, 15 14 }); 16 15 ··· 18 17 final bool hasMore; 19 18 final bool isLoading; 20 19 final VoidCallback onLoadMore; 21 - final Future<void> Function() onRefresh; 22 20 23 21 @override 24 22 State<MediaTab> createState() => _MediaTabState(); ··· 61 59 return const Center(child: Text('No media posts yet')); 62 60 } 63 61 64 - return RefreshIndicator( 65 - onRefresh: widget.onRefresh, 66 - child: ListView.builder( 67 - controller: _scrollController, 68 - physics: const AlwaysScrollableScrollPhysics(), 69 - itemCount: mediaItems.length + (widget.hasMore ? 1 : 0), 70 - itemBuilder: (context, index) { 71 - if (index >= mediaItems.length) { 72 - return const Padding( 73 - padding: EdgeInsets.all(16), 74 - child: Center(child: CircularProgressIndicator()), 75 - ); 76 - } 62 + return ListView.builder( 63 + controller: _scrollController, 64 + physics: const AlwaysScrollableScrollPhysics(), 65 + itemCount: mediaItems.length + (widget.hasMore ? 1 : 0), 66 + itemBuilder: (context, index) { 67 + if (index >= mediaItems.length) { 68 + return const Padding( 69 + padding: EdgeInsets.all(16), 70 + child: Center(child: CircularProgressIndicator()), 71 + ); 72 + } 77 73 78 - final item = mediaItems[index]; 79 - return FeedPostCard( 80 - uri: item.uri, 81 - authorDid: item.authorDid, 82 - authorHandle: item.authorHandle, 83 - authorDisplayName: item.authorDisplayName, 84 - authorAvatar: item.authorAvatar, 85 - text: item.text, 86 - indexedAt: item.indexedAt, 87 - replyCount: item.replyCount, 88 - repostCount: item.repostCount, 89 - likeCount: item.likeCount, 90 - onTap: () { 91 - final encodedUri = Uri.encodeComponent(item.uri); 92 - GoRouter.of(context).push('/home/t/$encodedUri'); 93 - }, 94 - onAvatarTap: () { 95 - final encodedDid = Uri.encodeComponent(item.authorDid); 96 - GoRouter.of(context).push('/home/u/$encodedDid'); 97 - }, 98 - ); 99 - }, 100 - ), 74 + final item = mediaItems[index]; 75 + return FeedPostCard( 76 + uri: item.uri, 77 + authorDid: item.authorDid, 78 + authorHandle: item.authorHandle, 79 + authorDisplayName: item.authorDisplayName, 80 + authorAvatar: item.authorAvatar, 81 + text: item.text, 82 + indexedAt: item.indexedAt, 83 + replyCount: item.replyCount, 84 + repostCount: item.repostCount, 85 + likeCount: item.likeCount, 86 + onTap: () { 87 + final encodedUri = Uri.encodeComponent(item.uri); 88 + GoRouter.of(context).push('/home/t/$encodedUri'); 89 + }, 90 + onAvatarTap: () { 91 + final encodedDid = Uri.encodeComponent(item.authorDid); 92 + GoRouter.of(context).push('/home/u/$encodedDid'); 93 + }, 94 + ); 95 + }, 101 96 ); 102 97 } 103 98 }
+33 -38
lib/src/features/profile/presentation/widgets/replies_tab.dart
··· 10 10 required this.hasMore, 11 11 required this.isLoading, 12 12 required this.onLoadMore, 13 - required this.onRefresh, 14 13 super.key, 15 14 }); 16 15 ··· 18 17 final bool hasMore; 19 18 final bool isLoading; 20 19 final VoidCallback onLoadMore; 21 - final Future<void> Function() onRefresh; 22 20 23 21 @override 24 22 State<RepliesTab> createState() => _RepliesTabState(); ··· 61 59 return const Center(child: Text('No replies yet')); 62 60 } 63 61 64 - return RefreshIndicator( 65 - onRefresh: widget.onRefresh, 66 - child: ListView.builder( 67 - controller: _scrollController, 68 - physics: const AlwaysScrollableScrollPhysics(), 69 - itemCount: replies.length + (widget.hasMore ? 1 : 0), 70 - itemBuilder: (context, index) { 71 - if (index >= replies.length) { 72 - return const Padding( 73 - padding: EdgeInsets.all(16), 74 - child: Center(child: CircularProgressIndicator()), 75 - ); 76 - } 62 + return ListView.builder( 63 + controller: _scrollController, 64 + physics: const AlwaysScrollableScrollPhysics(), 65 + itemCount: replies.length + (widget.hasMore ? 1 : 0), 66 + itemBuilder: (context, index) { 67 + if (index >= replies.length) { 68 + return const Padding( 69 + padding: EdgeInsets.all(16), 70 + child: Center(child: CircularProgressIndicator()), 71 + ); 72 + } 77 73 78 - final item = replies[index]; 79 - return FeedPostCard( 80 - uri: item.uri, 81 - authorDid: item.authorDid, 82 - authorHandle: item.authorHandle, 83 - authorDisplayName: item.authorDisplayName, 84 - authorAvatar: item.authorAvatar, 85 - text: item.text, 86 - indexedAt: item.indexedAt, 87 - replyCount: item.replyCount, 88 - repostCount: item.repostCount, 89 - likeCount: item.likeCount, 90 - onTap: () { 91 - final encodedUri = Uri.encodeComponent(item.uri); 92 - GoRouter.of(context).push('/home/t/$encodedUri'); 93 - }, 94 - onAvatarTap: () { 95 - final encodedDid = Uri.encodeComponent(item.authorDid); 96 - GoRouter.of(context).push('/home/u/$encodedDid'); 97 - }, 98 - ); 99 - }, 100 - ), 74 + final item = replies[index]; 75 + return FeedPostCard( 76 + uri: item.uri, 77 + authorDid: item.authorDid, 78 + authorHandle: item.authorHandle, 79 + authorDisplayName: item.authorDisplayName, 80 + authorAvatar: item.authorAvatar, 81 + text: item.text, 82 + indexedAt: item.indexedAt, 83 + replyCount: item.replyCount, 84 + repostCount: item.repostCount, 85 + likeCount: item.likeCount, 86 + onTap: () { 87 + final encodedUri = Uri.encodeComponent(item.uri); 88 + GoRouter.of(context).push('/home/t/$encodedUri'); 89 + }, 90 + onAvatarTap: () { 91 + final encodedDid = Uri.encodeComponent(item.authorDid); 92 + GoRouter.of(context).push('/home/u/$encodedDid'); 93 + }, 94 + ); 95 + }, 101 96 ); 102 97 } 103 98 }
+2 -2
lib/src/infrastructure/network/endpoint_registry.dart
··· 62 62 'app.bsky.feed.getFeed': const EndpointMeta( 63 63 nsid: 'app.bsky.feed.getFeed', 64 64 method: HttpMethod.get, 65 - hostKind: HostKind.pds, 66 - requiresAuth: true, 65 + hostKind: HostKind.publicApi, 66 + requiresAuth: false, 67 67 ), 68 68 'app.bsky.feed.getFeedGenerator': const EndpointMeta( 69 69 nsid: 'app.bsky.feed.getFeedGenerator',
+33 -30
lib/src/infrastructure/network/interceptors/auth_interceptor.dart
··· 133 133 @override 134 134 Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async { 135 135 final requiresAuth = options.extra[requiresAuthKey] == true; 136 - 137 - if (requiresAuth) { 138 - var session = await getSession(); 136 + var session = await getSession(); 139 137 140 - if (session != null && session.isNearExpiration && !session.isExpired) { 141 - _logger.debug('Token near expiration, proactively refreshing'); 142 - final refreshed = await _performRefresh(); 143 - if (refreshed != null) { 144 - session = refreshed; 145 - } else { 146 - _logger.warning('Proactive refresh failed, continuing with existing token'); 147 - } 138 + // Proactively refresh if token is near expiration 139 + if (session != null && session.isNearExpiration && !session.isExpired) { 140 + _logger.debug('Token near expiration, proactively refreshing'); 141 + final refreshed = await _performRefresh(); 142 + if (refreshed != null) { 143 + session = refreshed; 144 + } else { 145 + _logger.warning('Proactive refresh failed, continuing with existing token'); 148 146 } 147 + } 149 148 150 - if (session != null) { 151 - final token = session.accessJwt; 152 - options.headers['Authorization'] = 'DPoP $token'; 149 + // Attach auth headers if session is available 150 + // (required for requiresAuth endpoints, optional otherwise) 151 + if (session != null) { 152 + final token = session.accessJwt; 153 + options.headers['Authorization'] = 'DPoP $token'; 153 154 154 - try { 155 - final dpopKey = JsonWebKey.fromJson(session.dpopKey); 156 - final url = options.uri.toString(); 157 - final method = options.method; 158 - final nonce = _nonceStore.get(session.pdsUrl); 155 + try { 156 + final dpopKey = JsonWebKey.fromJson(session.dpopKey); 157 + final url = options.uri.toString(); 158 + final method = options.method; 159 + final nonce = _nonceStore.get(session.pdsUrl); 159 160 160 - final proof = await DPoPUtils.createProof( 161 - url: url, 162 - method: method, 163 - privateKey: dpopKey, 164 - accessToken: token, 165 - nonce: nonce, 166 - ); 161 + final proof = await DPoPUtils.createProof( 162 + url: url, 163 + method: method, 164 + privateKey: dpopKey, 165 + accessToken: token, 166 + nonce: nonce, 167 + ); 167 168 168 - options.headers['DPoP'] = proof; 169 - } catch (e) { 170 - _logger.warning('Failed to create DPoP proof for request', e); 171 - } 169 + options.headers['DPoP'] = proof; 170 + } catch (e) { 171 + _logger.warning('Failed to create DPoP proof for request', e); 172 172 } 173 + } else if (requiresAuth) { 174 + // Fail early if auth is required but no session exists 175 + _logger.warning('Request requires auth but no session available'); 173 176 } 174 177 175 178 handler.next(options);
-1
test/src/features/profile/presentation/widgets/media_tab_test.dart
··· 46 46 hasMore: hasMore, 47 47 isLoading: isLoading, 48 48 onLoadMore: () {}, 49 - onRefresh: () async {}, 50 49 ), 51 50 ), 52 51 );
-1
test/src/features/profile/presentation/widgets/replies_tab_test.dart
··· 46 46 hasMore: hasMore, 47 47 isLoading: isLoading, 48 48 onLoadMore: () {}, 49 - onRefresh: () async {}, 50 49 ), 51 50 ), 52 51 );
-1
test/src/infrastructure/network/endpoint_registry_test.dart
··· 187 187 test('authenticated read endpoints use pds host', () { 188 188 final authReads = [ 189 189 'app.bsky.feed.getTimeline', 190 - 'app.bsky.feed.getFeed', 191 190 'app.bsky.feed.searchPosts', 192 191 'app.bsky.notification.listNotifications', 193 192 'app.bsky.actor.getPreferences',
+5 -4
test/src/infrastructure/network/interceptors/auth_interceptor_test.dart
··· 44 44 expect(response.statusCode, equals(200)); 45 45 }); 46 46 47 - test('does not attach token for non-authenticated requests', () async { 47 + test('attaches token even for non-requiresAuth requests if session available', () async { 48 48 final dio = Dio(BaseOptions(baseUrl: 'https://test.api')); 49 49 final adapter = DioAdapter(dio: dio); 50 50 ··· 63 63 64 64 final response = await dio.get('/public'); 65 65 expect(response.statusCode, equals(200)); 66 - expect(sessionRequested, isFalse); 66 + expect(sessionRequested, isTrue); 67 67 }); 68 68 69 69 test('handles null session gracefully', () async { ··· 118 118 expect(options.headers['Authorization'], isNull); 119 119 }); 120 120 121 - test('skips auth when requiresAuth is false', () async { 121 + test('attaches auth even when requiresAuth is false if session available', () async { 122 122 final interceptor = AuthInterceptor( 123 123 getSession: () async => _createTestSession(), 124 124 refreshSession: () async => _createTestSession(accessJwt: 'refreshed'), ··· 128 128 129 129 await interceptor.onRequest(options, _NoOpRequestHandler()); 130 130 131 - expect(options.headers['Authorization'], isNull); 131 + expect(options.headers['Authorization'], isNotNull); 132 + expect(options.headers['Authorization'], startsWith('DPoP')); 132 133 }); 133 134 }); 134 135