+1
-2
lib/src/features/composer/presentation/widgets/global_compose_fab.dart
+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
+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
+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
+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
+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
+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
+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
+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
-1
test/src/features/profile/presentation/widgets/media_tab_test.dart
-1
test/src/features/profile/presentation/widgets/replies_tab_test.dart
-1
test/src/features/profile/presentation/widgets/replies_tab_test.dart
-1
test/src/infrastructure/network/endpoint_registry_test.dart
-1
test/src/infrastructure/network/endpoint_registry_test.dart
+5
-4
test/src/infrastructure/network/interceptors/auth_interceptor_test.dart
+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