+2
-2
lib/src/app/theme.dart
+2
-2
lib/src/app/theme.dart
···
107
107
108
108
/// Builds the text theme with custom fonts.
109
109
/// - Crimson Pro for display styles
110
-
/// - Atkinson Hyperlegible for body/label styles
110
+
/// - Merriweather Sans for body/label styles
111
111
/// - Fira Code for code (available via bodySmall)
112
112
static TextTheme _buildTextTheme(Color color) {
113
113
final displayStyle = GoogleFonts.crimsonPro(color: color);
114
-
final bodyStyle = GoogleFonts.atkinsonHyperlegible(color: color);
114
+
final bodyStyle = GoogleFonts.merriweatherSans(color: color);
115
115
final monoStyle = GoogleFonts.firaCode(color: color);
116
116
117
117
return TextTheme(
+2
-2
lib/src/app/theming/theme_factory.dart
+2
-2
lib/src/app/theming/theme_factory.dart
···
181
181
/// Builds the text theme with custom fonts.
182
182
///
183
183
/// - Crimson Pro for display styles
184
-
/// - Atkinson Hyperlegible for body/label styles
184
+
/// - Merriweather Sans for body/label styles
185
185
/// - Fira Code for code (available via bodySmall)
186
186
static TextTheme _buildTextTheme(Color color) {
187
187
final displayStyle = GoogleFonts.crimsonPro(color: color);
188
-
final bodyStyle = GoogleFonts.atkinsonHyperlegible(color: color);
188
+
final bodyStyle = GoogleFonts.merriweatherSans(color: color);
189
189
final monoStyle = GoogleFonts.firaCode(color: color);
190
190
191
191
return TextTheme(
+18
-20
lib/src/core/widgets/feed_post_card.dart
+18
-20
lib/src/core/widgets/feed_post_card.dart
···
20
20
this.likeCount = 0,
21
21
this.onTap,
22
22
this.onAvatarTap,
23
-
this.showDivider = true,
24
23
super.key,
25
24
});
26
25
···
36
35
final int likeCount;
37
36
final VoidCallback? onTap;
38
37
final VoidCallback? onAvatarTap;
39
-
final bool showDivider;
40
38
41
39
@override
42
40
Widget build(BuildContext context) {
43
41
final theme = Theme.of(context);
44
42
45
-
return Column(
46
-
children: [
47
-
InkWell(
48
-
onTap: onTap,
49
-
child: Padding(
50
-
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
51
-
child: Column(
52
-
crossAxisAlignment: CrossAxisAlignment.start,
53
-
children: [
54
-
_buildHeader(theme),
55
-
const SizedBox(height: 8),
56
-
_buildBody(theme),
57
-
const SizedBox(height: 8),
58
-
_buildActions(theme),
59
-
],
60
-
),
43
+
return Card(
44
+
clipBehavior: Clip.antiAlias,
45
+
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
46
+
elevation: 2,
47
+
child: InkWell(
48
+
onTap: onTap,
49
+
child: Padding(
50
+
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
51
+
child: Column(
52
+
crossAxisAlignment: CrossAxisAlignment.start,
53
+
children: [
54
+
_buildHeader(theme),
55
+
const SizedBox(height: 8),
56
+
_buildBody(theme),
57
+
const SizedBox(height: 8),
58
+
_buildActions(theme),
59
+
],
61
60
),
62
61
),
63
-
if (showDivider) const Divider(height: 1),
64
-
],
62
+
),
65
63
);
66
64
}
67
65
+7
-5
lib/src/features/composer/presentation/widgets/global_compose_fab.dart
+7
-5
lib/src/features/composer/presentation/widgets/global_compose_fab.dart
···
1
+
import 'package:flutter/cupertino.dart';
1
2
import 'package:flutter/material.dart';
2
3
import 'package:go_router/go_router.dart';
3
4
import 'package:lazurite/src/app/routes.dart';
···
6
7
///
7
8
/// Displays an extended FAB with "Post" label and edit icon.
8
9
/// Navigates to the composer screen when tapped.
9
-
/// Should be shown on main screens (Home, Search, Notifications, Profile)
10
-
/// and hidden on screens like Composer, Login, Drafts, and Settings.
10
+
///
11
+
/// Should be shown on main screens (Home, Search, Notifications, Profile) and hidden on screens
12
+
/// like Composer, Login, Drafts, and Settings.
11
13
class GlobalComposeFab extends StatelessWidget {
12
14
const GlobalComposeFab({super.key});
13
15
···
15
17
Widget build(BuildContext context) {
16
18
final theme = Theme.of(context);
17
19
18
-
return FloatingActionButton.extended(
20
+
return FloatingActionButton(
19
21
onPressed: () => context.push(AppRoutes.compose),
20
-
label: const Icon(Icons.edit_outlined),
21
22
backgroundColor: theme.colorScheme.primary,
22
23
foregroundColor: theme.colorScheme.onPrimary,
23
-
elevation: 6,
24
+
elevation: 4,
25
+
child: const Icon(CupertinoIcons.pencil_ellipsis_rectangle),
24
26
);
25
27
}
26
28
}
+3
-9
lib/src/features/feeds/presentation/screens/feed_screen.dart
+3
-9
lib/src/features/feeds/presentation/screens/feed_screen.dart
···
55
55
final authState = ref.watch(authProvider);
56
56
final isAuthenticated = authState is AuthStateAuthenticated;
57
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
58
if (isAuthenticated) {
61
59
final pinnedFeedsAsync = ref.watch(pinnedFeedsProvider);
62
60
if (pinnedFeedsAsync.isLoading) {
···
130
128
else
131
129
SliverList(
132
130
delegate: SliverChildBuilderDelegate((context, index) {
133
-
if (index.isOdd) {
134
-
return const Divider(height: 1);
135
-
}
136
-
final itemIndex = index ~/ 2;
137
131
return AnimatedItem(
138
-
index: itemIndex,
139
-
child: FeedPostCard(item: items[itemIndex]),
132
+
index: index,
133
+
child: FeedPostCard(item: items[index]),
140
134
);
141
-
}, childCount: items.length * 2 - 1),
135
+
}, childCount: items.length),
142
136
),
143
137
],
144
138
),
+44
-39
lib/src/features/feeds/presentation/screens/widgets/feed_post_card.dart
+44
-39
lib/src/features/feeds/presentation/screens/widgets/feed_post_card.dart
···
108
108
);
109
109
}
110
110
111
-
return InkWell(
112
-
onTap:
113
-
onTap ??
114
-
() {
115
-
final encodedUri = Uri.encodeComponent(item.post.uri);
116
-
GoRouter.of(context).push('/home/t/$encodedUri');
117
-
},
118
-
child: Padding(
119
-
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
120
-
child: Column(
121
-
crossAxisAlignment: CrossAxisAlignment.start,
122
-
children: [
123
-
if (isRepost && reposter != null)
124
-
Padding(
125
-
padding: const EdgeInsets.only(bottom: 4.0, left: 28.0),
126
-
child: Row(
127
-
children: [
128
-
Icon(
129
-
Icons.repeat,
130
-
size: 12,
131
-
color: Theme.of(context).colorScheme.onSurfaceVariant,
132
-
),
133
-
const SizedBox(width: 4),
134
-
Text(
135
-
'Reposted by ${reposter['displayName'] as String? ?? reposter['handle'] as String? ?? 'someone'}',
136
-
style: Theme.of(context).textTheme.bodySmall?.copyWith(
111
+
return Card(
112
+
clipBehavior: Clip.antiAlias,
113
+
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
114
+
elevation: 2,
115
+
child: InkWell(
116
+
onTap:
117
+
onTap ??
118
+
() {
119
+
final encodedUri = Uri.encodeComponent(item.post.uri);
120
+
GoRouter.of(context).push('/home/t/$encodedUri');
121
+
},
122
+
child: Padding(
123
+
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
124
+
child: Column(
125
+
crossAxisAlignment: CrossAxisAlignment.start,
126
+
children: [
127
+
if (isRepost && reposter != null)
128
+
Padding(
129
+
padding: const EdgeInsets.only(bottom: 4.0, left: 28.0),
130
+
child: Row(
131
+
children: [
132
+
Icon(
133
+
Icons.repeat,
134
+
size: 12,
137
135
color: Theme.of(context).colorScheme.onSurfaceVariant,
138
-
fontWeight: FontWeight.bold,
136
+
),
137
+
const SizedBox(width: 4),
138
+
Text(
139
+
'Reposted by ${reposter['displayName'] as String? ?? reposter['handle'] as String? ?? 'someone'}',
140
+
style: Theme.of(context).textTheme.bodySmall?.copyWith(
141
+
color: Theme.of(context).colorScheme.onSurfaceVariant,
142
+
fontWeight: FontWeight.bold,
143
+
),
139
144
),
140
-
),
141
-
],
145
+
],
146
+
),
142
147
),
148
+
PostHeader(
149
+
author: item.author,
150
+
indexedAt: createdAt,
151
+
onAvatarTap: () {
152
+
final encodedDid = Uri.encodeComponent(item.author.did);
153
+
GoRouter.of(context).push('/home/u/$encodedDid');
154
+
},
143
155
),
144
-
PostHeader(
145
-
author: item.author,
146
-
indexedAt: createdAt,
147
-
onAvatarTap: () {
148
-
final encodedDid = Uri.encodeComponent(item.author.did);
149
-
GoRouter.of(context).push('/home/u/$encodedDid');
150
-
},
151
-
),
152
-
Padding(padding: const EdgeInsets.only(left: 52.0), child: postContent),
153
-
],
156
+
Padding(padding: const EdgeInsets.only(left: 52.0), child: postContent),
157
+
],
158
+
),
154
159
),
155
160
),
156
161
);
-3
lib/src/features/feeds/presentation/widgets/feed_selector_tab.dart
-3
lib/src/features/feeds/presentation/widgets/feed_selector_tab.dart
···
61
61
height: 48,
62
62
child: Row(
63
63
children: [
64
-
// Scrollable feed chips
65
64
Expanded(
66
65
child: ListView.separated(
67
66
padding: const EdgeInsets.only(left: 16),
···
88
87
},
89
88
),
90
89
),
91
-
92
-
// Persistent manage feeds button
93
90
Stack(
94
91
alignment: Alignment.center,
95
92
children: [
+48
-1
lib/src/features/profile/infrastructure/profile_repository.dart
+48
-1
lib/src/features/profile/infrastructure/profile_repository.dart
···
25
25
_logger.info('Fetching profile', {'actor': actor});
26
26
try {
27
27
final response = await _api.call('app.bsky.actor.getProfile', params: {'actor': actor});
28
+
29
+
final pinnedRaw = response['pinnedPost'];
30
+
if (pinnedRaw != null) {
31
+
_logger.info('Received pinnedPost', {
32
+
'type': pinnedRaw.runtimeType.toString(),
33
+
'value': pinnedRaw.toString(),
34
+
});
35
+
} else {
36
+
_logger.info('No pinnedPost in response');
37
+
}
38
+
28
39
final profile = ProfileData.fromJson(response);
29
40
30
41
await _dao.upsertProfile(
···
352
363
final posts = response['posts'] as List?;
353
364
if (posts == null || posts.isEmpty) return null;
354
365
355
-
return FeedItem.fromJson(posts.first as Map<String, dynamic>);
366
+
return FeedItem.fromPostView(posts.first as Map<String, dynamic>);
356
367
} catch (e, stack) {
357
368
_logger.error('Failed to fetch post', e, stack);
358
369
rethrow;
···
547
558
548
559
/// Represents a single feed item from author feed.
549
560
class FeedItem {
561
+
factory FeedItem.fromPostView(Map<String, dynamic> json) {
562
+
final author = json['author'] as Map<String, dynamic>;
563
+
final record = json['record'] as Map<String, dynamic>;
564
+
final embed = json['embed'] as Map<String, dynamic>?;
565
+
final embedType = embed?[r'$type'] as String?;
566
+
final hasImages =
567
+
embedType == 'app.bsky.embed.images#view' ||
568
+
embedType == 'app.bsky.embed.recordWithMedia#view' &&
569
+
(embed?['media'] as Map<String, dynamic>?)?[r'$type'] == 'app.bsky.embed.images#view';
570
+
final hasVideo = embedType == 'app.bsky.embed.video#view';
571
+
final viewer = json['viewer'] as Map<String, dynamic>?;
572
+
573
+
return FeedItem(
574
+
uri: json['uri'] as String,
575
+
cid: json['cid'] as String,
576
+
authorDid: author['did'] as String,
577
+
authorHandle: author['handle'] as String,
578
+
authorDisplayName: author['displayName'] as String?,
579
+
authorAvatar: author['avatar'] as String?,
580
+
text: record['text'] as String? ?? '',
581
+
indexedAt: DateTime.tryParse(json['indexedAt'] as String? ?? ''),
582
+
replyCount: json['replyCount'] as int? ?? 0,
583
+
repostCount: json['repostCount'] as int? ?? 0,
584
+
likeCount: json['likeCount'] as int? ?? 0,
585
+
isReply: record['reply'] != null,
586
+
hasImages: hasImages,
587
+
hasVideo: hasVideo,
588
+
embedType: embedType,
589
+
record: record,
590
+
embed: embed,
591
+
viewerLikeUri: viewer?['like'] as String?,
592
+
viewerRepostUri: viewer?['repost'] as String?,
593
+
viewerBookmarked: viewer?['bookmarked'] as bool? ?? false,
594
+
);
595
+
}
596
+
550
597
factory FeedItem.fromJson(Map<String, dynamic> json) {
551
598
final post = json['post'] as Map<String, dynamic>;
552
599
final author = post['author'] as Map<String, dynamic>;
+7
-1
lib/src/features/profile/presentation/widgets/pinned_post_card.dart
+7
-1
lib/src/features/profile/presentation/widgets/pinned_post_card.dart
···
80
80
padding: EdgeInsets.all(16.0),
81
81
child: Center(child: CircularProgressIndicator()),
82
82
),
83
-
error: (error, stack) => const SizedBox.shrink(),
83
+
error: (error, stack) => Padding(
84
+
padding: const EdgeInsets.all(16.0),
85
+
child: Text(
86
+
'Error loading pinned post: $error',
87
+
style: TextStyle(color: Theme.of(context).colorScheme.error),
88
+
),
89
+
),
84
90
),
85
91
],
86
92
);
-4
lib/src/infrastructure/network/interceptors/auth_interceptor.dart
-4
lib/src/infrastructure/network/interceptors/auth_interceptor.dart
···
135
135
final requiresAuth = options.extra[requiresAuthKey] == true;
136
136
var session = await getSession();
137
137
138
-
// Proactively refresh if token is near expiration
139
138
if (session != null && session.isNearExpiration && !session.isExpired) {
140
139
_logger.debug('Token near expiration, proactively refreshing');
141
140
final refreshed = await _performRefresh();
···
146
145
}
147
146
}
148
147
149
-
// Attach auth headers if session is available
150
-
// (required for requiresAuth endpoints, optional otherwise)
151
148
if (session != null) {
152
149
final token = session.accessJwt;
153
150
options.headers['Authorization'] = 'DPoP $token';
···
171
168
_logger.warning('Failed to create DPoP proof for request', e);
172
169
}
173
170
} else if (requiresAuth) {
174
-
// Fail early if auth is required but no session exists
175
171
_logger.warning('Request requires auth but no session available');
176
172
}
177
173
-12
test/src/core/widgets/feed_post_card_test.dart
-12
test/src/core/widgets/feed_post_card_test.dart
···
16
16
int likeCount = 0,
17
17
VoidCallback? onTap,
18
18
VoidCallback? onAvatarTap,
19
-
bool showDivider = true,
20
19
}) {
21
20
return MaterialApp(
22
21
home: Scaffold(
···
33
32
likeCount: likeCount,
34
33
onTap: onTap,
35
34
onAvatarTap: onAvatarTap,
36
-
showDivider: showDivider,
37
35
),
38
36
),
39
37
);
···
90
88
await tester.pump();
91
89
92
90
expect(avatarTapped, true);
93
-
});
94
-
95
-
testWidgets('shows divider by default', (tester) async {
96
-
await tester.pumpWidget(createSubject(showDivider: true));
97
-
expect(find.byType(Divider), findsOneWidget);
98
-
});
99
-
100
-
testWidgets('hides divider when showDivider is false', (tester) async {
101
-
await tester.pumpWidget(createSubject(showDivider: false));
102
-
expect(find.byType(Divider), findsNothing);
103
91
});
104
92
105
93
testWidgets('renders relative time', (tester) async {
+2
-3
test/src/features/composer/presentation/widgets/global_compose_fab_test.dart
+2
-3
test/src/features/composer/presentation/widgets/global_compose_fab_test.dart
···
1
+
import 'package:flutter/cupertino.dart';
1
2
import 'package:flutter/material.dart';
2
3
import 'package:flutter_test/flutter_test.dart';
3
4
import 'package:go_router/go_router.dart';
···
11
12
testWidgets('renders extended FAB with Post label', (tester) async {
12
13
await tester.pumpApp(const GlobalComposeFab());
13
14
14
-
expect(find.text('Post'), findsOneWidget);
15
15
expect(find.byType(FloatingActionButton), findsOneWidget);
16
16
});
17
17
18
18
testWidgets('renders with edit icon', (tester) async {
19
19
await tester.pumpApp(const GlobalComposeFab());
20
-
21
-
expect(find.byIcon(Icons.edit_outlined), findsOneWidget);
20
+
expect(find.byIcon(CupertinoIcons.pencil_ellipsis_rectangle), findsOneWidget);
22
21
});
23
22
24
23
testWidgets('navigates to compose route when tapped', (tester) async {
+2
-2
test/src/features/feeds/presentation/widgets/feed_selector_tab_test.dart
+2
-2
test/src/features/feeds/presentation/widgets/feed_selector_tab_test.dart
···
111
111
expect(homeChipAfter.selected, isFalse);
112
112
});
113
113
114
-
testWidgets('FeedSelectorTab shows only Discover for unauthenticated users', (tester) async {
114
+
testWidgets('FeedSelectorTab is hidden for unauthenticated users', (tester) async {
115
115
final mockDatabase = MockAppDatabase();
116
116
117
117
await tester.pumpWidget(
···
126
126
127
127
await tester.pump();
128
128
129
-
expect(find.text('Discover'), findsOneWidget);
129
+
expect(find.text('Discover'), findsNothing);
130
130
expect(find.text('Home'), findsNothing);
131
131
expect(find.byIcon(Icons.tune), findsNothing);
132
132
});
+63
test/src/features/profile/infrastructure/profile_repository_pinned_post_test.dart
+63
test/src/features/profile/infrastructure/profile_repository_pinned_post_test.dart
···
1
+
import 'package:drift/native.dart';
2
+
import 'package:flutter_test/flutter_test.dart';
3
+
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
4
+
import 'package:lazurite/src/infrastructure/db/app_database.dart';
5
+
import 'package:mocktail/mocktail.dart';
6
+
7
+
import '../../../../helpers/mocks.dart';
8
+
9
+
void main() {
10
+
late MockXrpcClient mockApi;
11
+
late AppDatabase db;
12
+
late MockLogger mockLogger;
13
+
late ProfileRepository repository;
14
+
15
+
setUp(() {
16
+
mockApi = MockXrpcClient();
17
+
db = AppDatabase(NativeDatabase.memory());
18
+
mockLogger = MockLogger();
19
+
repository = ProfileRepository(
20
+
mockApi,
21
+
db.profileDao,
22
+
db.followsDao,
23
+
db.profileRelationshipDao,
24
+
mockLogger,
25
+
);
26
+
});
27
+
28
+
tearDown(() async {
29
+
await db.close();
30
+
});
31
+
32
+
group('ProfileRepository Pinned Post', () {
33
+
test('parses pinnedPost URI from API response', () async {
34
+
when(() => mockApi.call(any(), params: any(named: 'params'))).thenAnswer(
35
+
(_) async => {
36
+
'did': 'did:plc:test123',
37
+
'handle': 'test.bsky.social',
38
+
'pinnedPost': {
39
+
'uri': 'at://did:plc:test123/app.bsky.feed.post/pinned123',
40
+
'cid': 'bafybeicid123',
41
+
},
42
+
},
43
+
);
44
+
45
+
final profile = await repository.getProfile('test.bsky.social');
46
+
47
+
expect(profile.pinnedPostUri, 'at://did:plc:test123/app.bsky.feed.post/pinned123');
48
+
49
+
final cached = await db.profileDao.getProfile('did:plc:test123');
50
+
expect(cached?.pinnedPostUri, 'at://did:plc:test123/app.bsky.feed.post/pinned123');
51
+
});
52
+
53
+
test('handles missing pinnedPost', () async {
54
+
when(
55
+
() => mockApi.call(any(), params: any(named: 'params')),
56
+
).thenAnswer((_) async => {'did': 'did:plc:test123', 'handle': 'test.bsky.social'});
57
+
58
+
final profile = await repository.getProfile('test.bsky.social');
59
+
60
+
expect(profile.pinnedPostUri, isNull);
61
+
});
62
+
});
63
+
}
+49
test/src/features/profile/infrastructure/profile_repository_test.dart
+49
test/src/features/profile/infrastructure/profile_repository_test.dart
···
49
49
expect(profile.pronouns, 'they/them');
50
50
expect(profile.website, 'https://example.com');
51
51
expect(profile.verificationStatus, 'verified');
52
+
expect(profile.pinnedPostUri, 'at://did:plc:test123/app.bsky.feed.post/pinned123');
52
53
expect(profile.viewerFollowing, true);
53
54
54
55
verify(
···
392
393
expect(rel!.blocked, isTrue);
393
394
});
394
395
});
396
+
397
+
group('getPost', () {
398
+
test('fetches post and parses it correctly', () async {
399
+
when(
400
+
() => mockApi.call(any(), params: any(named: 'params')),
401
+
).thenAnswer((_) async => _mockGetPostsResponse());
402
+
403
+
final post = await repository.getPost('at://did:plc:test123/app.bsky.feed.post/1');
404
+
405
+
expect(post, isNotNull);
406
+
expect(post!.uri, 'at://did:plc:test123/app.bsky.feed.post/1');
407
+
expect(post.text, 'Hello world');
408
+
expect(post.authorDid, 'did:plc:test123');
409
+
expect(post.replyCount, 5);
410
+
});
411
+
412
+
test('returns null if no posts found', () async {
413
+
when(
414
+
() => mockApi.call(any(), params: any(named: 'params')),
415
+
).thenAnswer((_) async => {'posts': <dynamic>[]});
416
+
417
+
final post = await repository.getPost('at://did:plc:test123/app.bsky.feed.post/1');
418
+
419
+
expect(post, isNull);
420
+
});
421
+
});
395
422
});
396
423
}
397
424
425
+
Map<String, dynamic> _mockGetPostsResponse() => {
426
+
'posts': [
427
+
{
428
+
'uri': 'at://did:plc:test123/app.bsky.feed.post/1',
429
+
'cid': 'cid1',
430
+
'author': {
431
+
'did': 'did:plc:test123',
432
+
'handle': 'testuser.bsky.social',
433
+
'displayName': 'Test User',
434
+
'avatar': 'https://example.com/avatar.jpg',
435
+
},
436
+
'record': {'text': 'Hello world', 'createdAt': '2024-01-01T12:00:00.000Z'},
437
+
'indexedAt': '2024-01-01T12:00:00.000Z',
438
+
'replyCount': 5,
439
+
'repostCount': 3,
440
+
'likeCount': 10,
441
+
'viewer': {'like': 'at://did:plc:viewer/app.bsky.feed.like/123'},
442
+
},
443
+
],
444
+
};
445
+
398
446
Map<String, dynamic> _mockProfileResponse({bool withViewer = false}) => {
399
447
'did': 'did:plc:test123',
400
448
'handle': 'testuser.bsky.social',
···
409
457
'pronouns': 'they/them',
410
458
'website': 'https://example.com',
411
459
'verification': {'type': 'verified'},
460
+
'pinnedPost': {'uri': 'at://did:plc:test123/app.bsky.feed.post/pinned123', 'cid': 'cid123'},
412
461
if (withViewer)
413
462
'viewer': {
414
463
'following': 'at://did:plc:viewer/app.bsky.graph.follow/abc123',
+160
-5
test/src/features/profile/profile_screen_test.dart
+160
-5
test/src/features/profile/profile_screen_test.dart
···
2
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
3
import 'package:flutter_test/flutter_test.dart';
4
4
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
5
+
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
6
+
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
5
7
import 'package:lazurite/src/features/profile/presentation/profile_screen.dart';
6
8
import 'package:mocktail/mocktail.dart';
7
9
···
9
11
10
12
void main() {
11
13
late MockSessionStorage mockSessionStorage;
14
+
late MockProfileRepository mockProfileRepository;
12
15
13
16
setUp(() {
14
17
mockSessionStorage = MockSessionStorage();
18
+
mockProfileRepository = MockProfileRepository();
15
19
});
16
20
17
-
Widget createSubject() {
21
+
Widget createSubject({
22
+
required String did,
23
+
bool isCurrentUser = false,
24
+
List overrides = const [],
25
+
}) {
18
26
return ProviderScope(
19
-
overrides: [sessionStorageProvider.overrideWithValue(mockSessionStorage)],
20
-
child: const MaterialApp(home: ProfileScreen()),
27
+
overrides: [
28
+
sessionStorageProvider.overrideWithValue(mockSessionStorage),
29
+
profileRepositoryProvider.overrideWithValue(mockProfileRepository),
30
+
...overrides,
31
+
],
32
+
child: MaterialApp(home: ProfilePage(did: did)),
21
33
);
22
34
}
23
35
···
25
37
testWidgets('renders app bar with profile title', (tester) async {
26
38
when(() => mockSessionStorage.getSession()).thenAnswer((_) async => null);
27
39
28
-
await tester.pumpWidget(createSubject());
40
+
await tester.pumpWidget(createSubject(did: 'did:plc:test'));
29
41
await tester.pump();
30
42
31
43
expect(find.text('Profile'), findsWidgets);
···
34
46
testWidgets('shows sign in message when not authenticated', (tester) async {
35
47
when(() => mockSessionStorage.getSession()).thenAnswer((_) async => null);
36
48
37
-
await tester.pumpWidget(createSubject());
49
+
await tester.pumpWidget(
50
+
ProviderScope(
51
+
overrides: [sessionStorageProvider.overrideWithValue(mockSessionStorage)],
52
+
child: const MaterialApp(home: ProfileScreen()),
53
+
),
54
+
);
55
+
38
56
await tester.pump();
57
+
await tester.pump(const Duration(milliseconds: 100));
39
58
40
59
expect(find.text('Sign in to view your profile'), findsOneWidget);
41
60
});
61
+
62
+
testWidgets('displays pinned post when present', (tester) async {
63
+
final profile = ProfileData(
64
+
did: 'did:plc:test',
65
+
handle: 'test.bsky.social',
66
+
displayName: 'Test User',
67
+
pinnedPostUri: 'at://did:plc:test/app.bsky.feed.post/pinned',
68
+
);
69
+
70
+
final pinnedPost = FeedItem(
71
+
uri: 'at://did:plc:test/app.bsky.feed.post/pinned',
72
+
cid: 'cid1',
73
+
authorDid: 'did:plc:test',
74
+
authorHandle: 'test.bsky.social',
75
+
text: 'This is a pinned post',
76
+
indexedAt: DateTime.now(),
77
+
);
78
+
79
+
final feedItems = [
80
+
FeedItem(
81
+
uri: 'at://did:plc:test/app.bsky.feed.post/1',
82
+
cid: 'cid2',
83
+
authorDid: 'did:plc:test',
84
+
authorHandle: 'test.bsky.social',
85
+
text: 'Regular post',
86
+
indexedAt: DateTime.now(),
87
+
),
88
+
];
89
+
90
+
when(() => mockSessionStorage.getSession()).thenAnswer((_) async => null);
91
+
92
+
await tester.pumpWidget(
93
+
createSubject(
94
+
did: 'did:plc:test',
95
+
overrides: [
96
+
profileProvider('did:plc:test').overrideWith(() => MockProfileNotifier(profile)),
97
+
authorFeedProvider(
98
+
'did:plc:test',
99
+
).overrideWith(() => MockAuthorFeedNotifier(feedItems)),
100
+
pinnedPostProvider(
101
+
'at://did:plc:test/app.bsky.feed.post/pinned',
102
+
).overrideWith((ref) => Future.value(pinnedPost)),
103
+
],
104
+
),
105
+
);
106
+
107
+
await tester.pump();
108
+
await tester.pump(const Duration(milliseconds: 100));
109
+
await tester.pump(const Duration(milliseconds: 100));
110
+
111
+
expect(find.text('Pinned Post'), findsOneWidget);
112
+
expect(find.text('This is a pinned post'), findsOneWidget);
113
+
expect(find.text('Regular post'), findsOneWidget);
114
+
});
115
+
116
+
testWidgets('does not duplicate pinned post in feed', (tester) async {
117
+
final profile = ProfileData(
118
+
did: 'did:plc:test',
119
+
handle: 'test.bsky.social',
120
+
displayName: 'Test User',
121
+
pinnedPostUri: 'at://did:plc:test/app.bsky.feed.post/pinned',
122
+
);
123
+
124
+
final pinnedPost = FeedItem(
125
+
uri: 'at://did:plc:test/app.bsky.feed.post/pinned',
126
+
cid: 'cid1',
127
+
authorDid: 'did:plc:test',
128
+
authorHandle: 'test.bsky.social',
129
+
text: 'This is a pinned post',
130
+
indexedAt: DateTime.now(),
131
+
);
132
+
133
+
final feedItems = [
134
+
pinnedPost,
135
+
FeedItem(
136
+
uri: 'at://did:plc:test/app.bsky.feed.post/1',
137
+
cid: 'cid2',
138
+
authorDid: 'did:plc:test',
139
+
authorHandle: 'test.bsky.social',
140
+
text: 'Regular post',
141
+
indexedAt: DateTime.now(),
142
+
),
143
+
];
144
+
145
+
when(() => mockSessionStorage.getSession()).thenAnswer((_) async => null);
146
+
147
+
await tester.pumpWidget(
148
+
createSubject(
149
+
did: 'did:plc:test',
150
+
overrides: [
151
+
profileProvider('did:plc:test').overrideWith(() => MockProfileNotifier(profile)),
152
+
authorFeedProvider(
153
+
'did:plc:test',
154
+
).overrideWith(() => MockAuthorFeedNotifier(feedItems)),
155
+
pinnedPostProvider(
156
+
'at://did:plc:test/app.bsky.feed.post/pinned',
157
+
).overrideWith((ref) => Future.value(pinnedPost)),
158
+
],
159
+
),
160
+
);
161
+
162
+
await tester.pump();
163
+
await tester.pump(const Duration(milliseconds: 100));
164
+
await tester.pump(const Duration(milliseconds: 100));
165
+
166
+
expect(find.text('Pinned Post'), findsOneWidget);
167
+
expect(find.text('This is a pinned post'), findsOneWidget);
168
+
expect(find.text('Regular post'), findsOneWidget);
169
+
});
42
170
});
43
171
}
172
+
173
+
class MockProfileRepository extends Mock implements ProfileRepository {}
174
+
175
+
class MockProfileNotifier extends ProfileNotifier {
176
+
MockProfileNotifier(this._data);
177
+
final ProfileData _data;
178
+
179
+
@override
180
+
Future<ProfileData> build(String actor) async => _data;
181
+
}
182
+
183
+
class MockAuthorFeedNotifier extends AuthorFeedNotifier {
184
+
MockAuthorFeedNotifier(this._items);
185
+
final List<FeedItem> _items;
186
+
187
+
@override
188
+
Future<List<FeedItem>> build(String actor) async => _items;
189
+
190
+
@override
191
+
Future<void> loadMore() async {}
192
+
193
+
@override
194
+
Future<void> refresh() async {}
195
+
196
+
@override
197
+
bool get hasMore => false;
198
+
}