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

feat: get pinned post from profile data

* change fab icon

Changed files
+407 -108
lib
src
app
core
features
composer
presentation
feeds
presentation
profile
infrastructure
presentation
infrastructure
network
interceptors
test
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 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
··· 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 + }