diff --git a/docs/BUGS.md b/docs/BUGS.md index 0f2f8ce..5f4ffd2 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -6,8 +6,8 @@ updated: 2026-03-17 ## Checklist - [x] [1. Post Thread Screen](#1-post-thread-screen) -- [ ] [2. Post Tap Navigation](#2-post-tap-navigation) -- [ ] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) +- [x] [2. Post Tap Navigation](#2-post-tap-navigation) +- [x] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) - [ ] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) - [ ] [5. Notification Tap Navigation](#5-notification-tap-navigation) - [ ] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) diff --git a/lib/features/feed/presentation/widgets/post_card.dart b/lib/features/feed/presentation/widgets/post_card.dart index 2dbb69f..36f5575 100644 --- a/lib/features/feed/presentation/widgets/post_card.dart +++ b/lib/features/feed/presentation/widgets/post_card.dart @@ -13,10 +13,11 @@ import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:url_launcher/url_launcher.dart'; class PostCard extends StatelessWidget { - const PostCard({super.key, required this.feedViewPost, this.actionBar}); + const PostCard({super.key, required this.feedViewPost, this.actionBar, this.onTap}); final FeedViewPost feedViewPost; final Widget? actionBar; + final VoidCallback? onTap; @override Widget build(BuildContext context) { @@ -28,22 +29,33 @@ class PostCard extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), elevation: 0, shape: const RoundedRectangleBorder(), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context, post.author, record?.createdAt ?? post.indexedAt), - if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], - if (record != null && record.text.isNotEmpty) ...[ - const SizedBox(height: 12), - FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), - ], - if (embed != null) ...[const SizedBox(height: 12), embed], - const SizedBox(height: 12), - actionBar ?? _buildActions(context), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context, post.author, record?.createdAt ?? post.indexedAt), + if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], + if (record != null && record.text.isNotEmpty) ...[ + const SizedBox(height: 12), + FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), + ], + if (embed != null) ...[const SizedBox(height: 12), embed], + const SizedBox(height: 12), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: actionBar ?? _buildActions(context), + ), + ], ), ); } @@ -52,13 +64,16 @@ class PostCard extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 22, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, - child: author.avatar == null - ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) - : null, + GestureDetector( + onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), + child: CircleAvatar( + radius: 22, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, + child: author.avatar == null + ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) + : null, + ), ), const SizedBox(width: 12), Expanded( diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index 9ea0bcf..c56aa8e 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -62,7 +62,11 @@ class _PostCardWithActionsContent extends StatelessWidget { context.read().clearError(); } }, - child: PostCard(feedViewPost: feedViewPost, actionBar: _buildActionBar(context)), + child: PostCard( + feedViewPost: feedViewPost, + actionBar: _buildActionBar(context), + onTap: () => context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}'), + ), ); } diff --git a/test/features/feed/presentation/post_card_test.dart b/test/features/feed/presentation/post_card_test.dart index d8fd0f3..513166a 100644 --- a/test/features/feed/presentation/post_card_test.dart +++ b/test/features/feed/presentation/post_card_test.dart @@ -8,12 +8,26 @@ import 'package:bluesky/app_bsky_feed_post.dart'; import 'package:bluesky/app_bsky_richtext_facet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; +FeedViewPost _makePost({String text = 'Hello'}) { + final record = FeedPostRecord(text: text, createdAt: DateTime.utc(2026, 3, 16)); + return FeedViewPost( + post: PostView( + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), + cid: 'cid-xyz', + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), + record: record.toJson(), + indexedAt: DateTime.utc(2026, 3, 16), + ), + ); +} + void main() { - Widget buildSubject(FeedViewPost post) { + Widget buildSubject(FeedViewPost post, {VoidCallback? onTap}) { return MaterialApp( - home: Scaffold(body: PostCard(feedViewPost: post)), + home: Scaffold(body: PostCard(feedViewPost: post, onTap: onTap)), ); } @@ -79,4 +93,52 @@ void main() { expect(find.text('A useful external card'), findsOneWidget); expect(find.text('example.com'), findsOneWidget); }); + + testWidgets('calls onTap when content area is tapped', (tester) async { + var tapped = false; + final post = _makePost(); + + await tester.pumpWidget(buildSubject(post, onTap: () => tapped = true)); + + // Tap the author handle which is in the content InkWell (not the action bar). + await tester.tap(find.text('test.bsky.social', findRichText: true).first); + expect(tapped, isTrue); + }); + + testWidgets('does not call onTap when onTap is null', (tester) async { + final post = _makePost(); + await tester.pumpWidget(buildSubject(post)); + // Should not throw when tapping without a callback. + await tester.tap(find.text('test.bsky.social', findRichText: true).first); + await tester.pump(); + }); + + testWidgets('tapping avatar navigates to author profile', (tester) async { + final post = _makePost(); + String? pushedRoute; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: PostCard(feedViewPost: post)), + ), + GoRoute( + path: '/profile/view', + builder: (context, state) { + pushedRoute = state.uri.toString(); + return const Scaffold(body: Text('profile')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + + expect(pushedRoute, contains('did%3Aplc%3Atest')); + }); }