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

Configure Feed

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

feat: post & avatar navigation

+110 -29
+2 -2
docs/BUGS.md
··· 6 6 ## Checklist 7 7 8 8 - [x] [1. Post Thread Screen](#1-post-thread-screen) 9 - - [ ] [2. Post Tap Navigation](#2-post-tap-navigation) 10 - - [ ] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) 9 + - [x] [2. Post Tap Navigation](#2-post-tap-navigation) 10 + - [x] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) 11 11 - [ ] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) 12 12 - [ ] [5. Notification Tap Navigation](#5-notification-tap-navigation) 13 13 - [ ] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts)
+39 -24
lib/features/feed/presentation/widgets/post_card.dart
··· 13 13 import 'package:url_launcher/url_launcher.dart'; 14 14 15 15 class PostCard extends StatelessWidget { 16 - const PostCard({super.key, required this.feedViewPost, this.actionBar}); 16 + const PostCard({super.key, required this.feedViewPost, this.actionBar, this.onTap}); 17 17 18 18 final FeedViewPost feedViewPost; 19 19 final Widget? actionBar; 20 + final VoidCallback? onTap; 20 21 21 22 @override 22 23 Widget build(BuildContext context) { ··· 28 29 margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), 29 30 elevation: 0, 30 31 shape: const RoundedRectangleBorder(), 31 - child: Padding( 32 - padding: const EdgeInsets.all(16), 33 - child: Column( 34 - crossAxisAlignment: CrossAxisAlignment.start, 35 - children: [ 36 - _buildHeader(context, post.author, record?.createdAt ?? post.indexedAt), 37 - if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], 38 - if (record != null && record.text.isNotEmpty) ...[ 39 - const SizedBox(height: 12), 40 - FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), 41 - ], 42 - if (embed != null) ...[const SizedBox(height: 12), embed], 43 - const SizedBox(height: 12), 44 - actionBar ?? _buildActions(context), 45 - ], 46 - ), 32 + child: Column( 33 + crossAxisAlignment: CrossAxisAlignment.start, 34 + children: [ 35 + InkWell( 36 + onTap: onTap, 37 + child: Padding( 38 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), 39 + child: Column( 40 + crossAxisAlignment: CrossAxisAlignment.start, 41 + children: [ 42 + _buildHeader(context, post.author, record?.createdAt ?? post.indexedAt), 43 + if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], 44 + if (record != null && record.text.isNotEmpty) ...[ 45 + const SizedBox(height: 12), 46 + FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), 47 + ], 48 + if (embed != null) ...[const SizedBox(height: 12), embed], 49 + const SizedBox(height: 12), 50 + ], 51 + ), 52 + ), 53 + ), 54 + Padding( 55 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), 56 + child: actionBar ?? _buildActions(context), 57 + ), 58 + ], 47 59 ), 48 60 ); 49 61 } ··· 52 64 return Row( 53 65 crossAxisAlignment: CrossAxisAlignment.start, 54 66 children: [ 55 - CircleAvatar( 56 - radius: 22, 57 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 58 - backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, 59 - child: author.avatar == null 60 - ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) 61 - : null, 67 + GestureDetector( 68 + onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 69 + child: CircleAvatar( 70 + radius: 22, 71 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 72 + backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, 73 + child: author.avatar == null 74 + ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) 75 + : null, 76 + ), 62 77 ), 63 78 const SizedBox(width: 12), 64 79 Expanded(
+5 -1
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 62 62 context.read<PostActionCubit>().clearError(); 63 63 } 64 64 }, 65 - child: PostCard(feedViewPost: feedViewPost, actionBar: _buildActionBar(context)), 65 + child: PostCard( 66 + feedViewPost: feedViewPost, 67 + actionBar: _buildActionBar(context), 68 + onTap: () => context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}'), 69 + ), 66 70 ); 67 71 } 68 72
+64 -2
test/features/feed/presentation/post_card_test.dart
··· 8 8 import 'package:bluesky/app_bsky_richtext_facet.dart'; 9 9 import 'package:flutter/material.dart'; 10 10 import 'package:flutter_test/flutter_test.dart'; 11 + import 'package:go_router/go_router.dart'; 11 12 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 12 13 14 + FeedViewPost _makePost({String text = 'Hello'}) { 15 + final record = FeedPostRecord(text: text, createdAt: DateTime.utc(2026, 3, 16)); 16 + return FeedViewPost( 17 + post: PostView( 18 + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), 19 + cid: 'cid-xyz', 20 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 21 + record: record.toJson(), 22 + indexedAt: DateTime.utc(2026, 3, 16), 23 + ), 24 + ); 25 + } 26 + 13 27 void main() { 14 - Widget buildSubject(FeedViewPost post) { 28 + Widget buildSubject(FeedViewPost post, {VoidCallback? onTap}) { 15 29 return MaterialApp( 16 - home: Scaffold(body: PostCard(feedViewPost: post)), 30 + home: Scaffold(body: PostCard(feedViewPost: post, onTap: onTap)), 17 31 ); 18 32 } 19 33 ··· 78 92 expect(find.text('Example Article'), findsOneWidget); 79 93 expect(find.text('A useful external card'), findsOneWidget); 80 94 expect(find.text('example.com'), findsOneWidget); 95 + }); 96 + 97 + testWidgets('calls onTap when content area is tapped', (tester) async { 98 + var tapped = false; 99 + final post = _makePost(); 100 + 101 + await tester.pumpWidget(buildSubject(post, onTap: () => tapped = true)); 102 + 103 + // Tap the author handle which is in the content InkWell (not the action bar). 104 + await tester.tap(find.text('test.bsky.social', findRichText: true).first); 105 + expect(tapped, isTrue); 106 + }); 107 + 108 + testWidgets('does not call onTap when onTap is null', (tester) async { 109 + final post = _makePost(); 110 + await tester.pumpWidget(buildSubject(post)); 111 + // Should not throw when tapping without a callback. 112 + await tester.tap(find.text('test.bsky.social', findRichText: true).first); 113 + await tester.pump(); 114 + }); 115 + 116 + testWidgets('tapping avatar navigates to author profile', (tester) async { 117 + final post = _makePost(); 118 + String? pushedRoute; 119 + 120 + final router = GoRouter( 121 + routes: [ 122 + GoRoute( 123 + path: '/', 124 + builder: (context, state) => Scaffold(body: PostCard(feedViewPost: post)), 125 + ), 126 + GoRoute( 127 + path: '/profile/view', 128 + builder: (context, state) { 129 + pushedRoute = state.uri.toString(); 130 + return const Scaffold(body: Text('profile')); 131 + }, 132 + ), 133 + ], 134 + ); 135 + 136 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 137 + await tester.pumpAndSettle(); 138 + 139 + await tester.tap(find.byType(CircleAvatar)); 140 + await tester.pumpAndSettle(); 141 + 142 + expect(pushedRoute, contains('did%3Aplc%3Atest')); 81 143 }); 82 144 }