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 thread screen

+1143 -1
+1 -1
docs/BUGS.md
··· 5 5 6 6 ## Checklist 7 7 8 - - [ ] [1. Post Thread Screen](#1-post-thread-screen) 8 + - [x] [1. Post Thread Screen](#1-post-thread-screen) 9 9 - [ ] [2. Post Tap Navigation](#2-post-tap-navigation) 10 10 - [ ] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) 11 11 - [ ] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation)
+9
lib/core/router/app_router.dart
··· 15 15 import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 16 16 import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; 17 17 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 18 + import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; 18 19 import 'package:lazurite/features/logs/presentation/logs_screen.dart'; 19 20 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 20 21 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; ··· 80 81 draftId: args?.draftId, 81 82 ), 82 83 ); 84 + }, 85 + ), 86 + GoRoute( 87 + path: '/post', 88 + parentNavigatorKey: _rootNavigatorKey, 89 + builder: (context, state) { 90 + final uri = state.uri.queryParameters['uri'] ?? ''; 91 + return PostThreadScreen(postUri: Uri.decodeComponent(uri)); 83 92 }, 84 93 ), 85 94 GoRoute(
+37
lib/features/feed/cubit/post_thread_cubit.dart
··· 1 + import 'package:bluesky/app_bsky_feed_defs.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/core/logging/app_logger.dart'; 5 + import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 6 + 7 + enum PostThreadStatus { loading, loaded, error } 8 + 9 + class PostThreadState extends Equatable { 10 + const PostThreadState({this.status = PostThreadStatus.loading, this.thread, this.error}); 11 + 12 + final PostThreadStatus status; 13 + final ThreadViewPost? thread; 14 + final String? error; 15 + 16 + @override 17 + List<Object?> get props => [status, thread, error]; 18 + } 19 + 20 + class PostThreadCubit extends Cubit<PostThreadState> { 21 + PostThreadCubit({required PostThreadRepository postThreadRepository}) 22 + : _postThreadRepository = postThreadRepository, 23 + super(const PostThreadState()); 24 + 25 + final PostThreadRepository _postThreadRepository; 26 + 27 + Future<void> load(String uri) async { 28 + emit(const PostThreadState(status: PostThreadStatus.loading)); 29 + try { 30 + final thread = await _postThreadRepository.getPostThread(uri); 31 + emit(PostThreadState(status: PostThreadStatus.loaded, thread: thread)); 32 + } catch (error) { 33 + log.e('Failed to load thread', error: error); 34 + emit(const PostThreadState(status: PostThreadStatus.error, error: 'Failed to load thread')); 35 + } 36 + } 37 + }
+29
lib/features/feed/data/post_thread_repository.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_feed_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_getpostthread.dart'; 4 + import 'package:bluesky/bluesky.dart'; 5 + 6 + class PostThreadRepository { 7 + PostThreadRepository({required Bluesky bluesky}) : _bluesky = bluesky; 8 + 9 + final Bluesky _bluesky; 10 + 11 + Future<ThreadViewPost> getPostThread(String uri) async { 12 + final response = await _bluesky.feed.getPostThread(uri: AtUri.parse(uri)); 13 + final thread = response.data.thread; 14 + 15 + if (thread.isThreadViewPost) { 16 + return thread.threadViewPost!; 17 + } 18 + 19 + if (thread.isNotFoundPost) { 20 + throw Exception('Post not found'); 21 + } 22 + 23 + if (thread.isBlockedPost) { 24 + throw Exception('Post is from a blocked account'); 25 + } 26 + 27 + throw Exception('Unable to load thread'); 28 + } 29 + }
+451
lib/features/feed/presentation/post_thread_screen.dart
··· 1 + import 'dart:async'; 2 + import 'dart:convert'; 3 + 4 + import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:bluesky/app_bsky_feed_post.dart'; 6 + import 'package:bluesky/bluesky.dart'; 7 + import 'package:flutter/material.dart'; 8 + import 'package:flutter/services.dart'; 9 + import 'package:flutter_bloc/flutter_bloc.dart'; 10 + import 'package:go_router/go_router.dart'; 11 + import 'package:intl/intl.dart'; 12 + import 'package:lazurite/core/logging/app_logger.dart'; 13 + import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 14 + import 'package:lazurite/features/feed/cubit/post_thread_cubit.dart'; 15 + import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 16 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 17 + import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 18 + import 'package:lazurite/features/feed/presentation/widgets/post_action_bar.dart'; 19 + import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 20 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 21 + import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 22 + import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 23 + import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 24 + 25 + class PostThreadScreen extends StatelessWidget { 26 + const PostThreadScreen({super.key, required this.postUri}); 27 + 28 + final String postUri; 29 + 30 + @override 31 + Widget build(BuildContext context) { 32 + return BlocProvider( 33 + create: (_) => 34 + PostThreadCubit(postThreadRepository: PostThreadRepository(bluesky: context.read<Bluesky>()))..load(postUri), 35 + child: _PostThreadContent(postUri: postUri), 36 + ); 37 + } 38 + } 39 + 40 + class _PostThreadContent extends StatelessWidget { 41 + const _PostThreadContent({required this.postUri}); 42 + 43 + final String postUri; 44 + 45 + @override 46 + Widget build(BuildContext context) { 47 + return Scaffold( 48 + appBar: AppBar(title: const Text('Thread')), 49 + body: BlocBuilder<PostThreadCubit, PostThreadState>( 50 + builder: (context, state) { 51 + return switch (state.status) { 52 + PostThreadStatus.loading => const Center(child: CircularProgressIndicator()), 53 + PostThreadStatus.error => _buildError(context, state.error ?? 'Failed to load thread'), 54 + PostThreadStatus.loaded => _buildThread(context, state.thread!), 55 + }; 56 + }, 57 + ), 58 + ); 59 + } 60 + 61 + Widget _buildError(BuildContext context, String message) { 62 + return Center( 63 + child: Column( 64 + mainAxisAlignment: MainAxisAlignment.center, 65 + children: [ 66 + const Icon(Icons.error_outline, size: 48, color: Colors.grey), 67 + const SizedBox(height: 16), 68 + Text(message), 69 + const SizedBox(height: 16), 70 + FilledButton(onPressed: () => context.read<PostThreadCubit>().load(postUri), child: const Text('Retry')), 71 + ], 72 + ), 73 + ); 74 + } 75 + 76 + Widget _buildThread(BuildContext context, ThreadViewPost thread) { 77 + final accountDid = context.read<String>(); 78 + final parents = _getParentChain(thread); 79 + final replies = (thread.replies ?? []).where((r) => r.isThreadViewPost).map((r) => r.threadViewPost!).toList(); 80 + 81 + return ListView( 82 + children: [ 83 + for (int i = 0; i < parents.length; i++) ...[ 84 + PostCardWithActions( 85 + feedViewPost: FeedViewPost(post: parents[i].post), 86 + accountDid: accountDid, 87 + ), 88 + _buildThreadConnector(context), 89 + ], 90 + _FocusedPostWithActions(thread: thread, accountDid: accountDid), 91 + if (replies.isNotEmpty) ...[ 92 + Padding( 93 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), 94 + child: Text( 95 + 'Replies', 96 + style: Theme.of(context).textTheme.labelSmall?.copyWith( 97 + color: Theme.of(context).colorScheme.onSurfaceVariant, 98 + fontWeight: FontWeight.w600, 99 + letterSpacing: 0.5, 100 + ), 101 + ), 102 + ), 103 + const Divider(height: 1), 104 + for (final reply in replies) 105 + PostCardWithActions( 106 + feedViewPost: FeedViewPost(post: reply.post), 107 + accountDid: accountDid, 108 + ), 109 + ], 110 + ], 111 + ); 112 + } 113 + 114 + Widget _buildThreadConnector(BuildContext context) { 115 + return SizedBox( 116 + height: 16, 117 + child: Row( 118 + children: [ 119 + const SizedBox(width: 37), 120 + Container(width: 2, color: Theme.of(context).dividerColor), 121 + ], 122 + ), 123 + ); 124 + } 125 + 126 + List<ThreadViewPost> _getParentChain(ThreadViewPost thread) { 127 + final parents = <ThreadViewPost>[]; 128 + var current = thread.parent; 129 + while (current != null && current.isThreadViewPost) { 130 + final parentThread = current.threadViewPost!; 131 + parents.add(parentThread); 132 + current = parentThread.parent; 133 + } 134 + return parents.reversed.toList(); 135 + } 136 + } 137 + 138 + class _FocusedPostWithActions extends StatelessWidget { 139 + const _FocusedPostWithActions({required this.thread, required this.accountDid}); 140 + 141 + final ThreadViewPost thread; 142 + final String accountDid; 143 + 144 + @override 145 + Widget build(BuildContext context) { 146 + final post = thread.post; 147 + final viewer = post.viewer; 148 + 149 + return BlocProvider( 150 + create: (_) => PostActionCubit( 151 + postActionRepository: context.read<PostActionRepository>(), 152 + postUri: post.uri.toString(), 153 + postCid: post.cid, 154 + isLiked: viewer?.like != null, 155 + isReposted: viewer?.repost != null, 156 + likeCount: post.likeCount ?? 0, 157 + repostCount: post.repostCount ?? 0, 158 + likeUri: viewer?.like?.toString(), 159 + repostUri: viewer?.repost?.toString(), 160 + ), 161 + child: BlocListener<PostActionCubit, PostActionState>( 162 + listenWhen: (previous, current) => previous.error != current.error && current.error != null, 163 + listener: (context, state) { 164 + ScaffoldMessenger.of( 165 + context, 166 + ).showSnackBar(SnackBar(content: Text(state.error!), behavior: SnackBarBehavior.floating)); 167 + context.read<PostActionCubit>().clearError(); 168 + }, 169 + child: _FocusedPostContent(thread: thread, accountDid: accountDid), 170 + ), 171 + ); 172 + } 173 + } 174 + 175 + class _FocusedPostContent extends StatelessWidget { 176 + const _FocusedPostContent({required this.thread, required this.accountDid}); 177 + 178 + final ThreadViewPost thread; 179 + final String accountDid; 180 + 181 + @override 182 + Widget build(BuildContext context) { 183 + final post = thread.post; 184 + final record = _tryParseRecord(post.record); 185 + final timestamp = record?.createdAt ?? post.indexedAt; 186 + 187 + return PostCard( 188 + feedViewPost: FeedViewPost(post: post), 189 + actionBar: Column( 190 + crossAxisAlignment: CrossAxisAlignment.start, 191 + children: [ 192 + const SizedBox(height: 4), 193 + Text( 194 + _formatTimestamp(timestamp), 195 + style: Theme.of( 196 + context, 197 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 198 + ), 199 + const Divider(), 200 + _buildStats(context, post), 201 + const Divider(height: 1), 202 + const SizedBox(height: 4), 203 + _buildActionBar(context, post), 204 + ], 205 + ), 206 + ); 207 + } 208 + 209 + Widget _buildStats(BuildContext context, PostView post) { 210 + final items = <Widget>[]; 211 + 212 + if ((post.replyCount ?? 0) > 0) { 213 + items.addAll([_buildStat(context, post.replyCount!, 'replies'), const SizedBox(width: 20)]); 214 + } 215 + if ((post.repostCount ?? 0) > 0) { 216 + items.addAll([_buildStat(context, post.repostCount!, 'reposts'), const SizedBox(width: 20)]); 217 + } 218 + if ((post.likeCount ?? 0) > 0) { 219 + items.add(_buildStat(context, post.likeCount!, 'likes')); 220 + } 221 + 222 + if (items.isEmpty) return const SizedBox.shrink(); 223 + 224 + return Padding( 225 + padding: const EdgeInsets.symmetric(vertical: 8), 226 + child: Row(children: items), 227 + ); 228 + } 229 + 230 + Widget _buildStat(BuildContext context, int count, String label) { 231 + return RichText( 232 + text: TextSpan( 233 + children: [ 234 + TextSpan( 235 + text: '$count', 236 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), 237 + ), 238 + TextSpan( 239 + text: ' $label', 240 + style: Theme.of( 241 + context, 242 + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 243 + ), 244 + ], 245 + ), 246 + ); 247 + } 248 + 249 + Widget _buildActionBar(BuildContext context, PostView post) { 250 + return BlocBuilder<PostActionCubit, PostActionState>( 251 + builder: (context, postActionState) { 252 + return BlocBuilder<SavedPostsCubit, SavedPostsState>( 253 + builder: (context, savedState) { 254 + return PostActionBar( 255 + replyCount: 0, 256 + repostCount: 0, 257 + likeCount: 0, 258 + isLiked: postActionState.isLiked, 259 + isReposted: postActionState.isReposted, 260 + isSaved: savedState.isSaved(post.uri.toString()), 261 + postUri: post.uri.toString(), 262 + postCid: post.cid, 263 + isLoadingLike: postActionState.isLoadingLike, 264 + isLoadingRepost: postActionState.isLoadingRepost, 265 + onReply: () => _onReply(context), 266 + onRepost: () => context.read<PostActionCubit>().toggleRepost(), 267 + onQuote: () => _onQuote(context), 268 + onLike: () => context.read<PostActionCubit>().toggleLike(), 269 + onSave: () { 270 + unawaited(_onToggleSave(context)); 271 + }, 272 + onMore: () => _showMoreOptions(context), 273 + ); 274 + }, 275 + ); 276 + }, 277 + ); 278 + } 279 + 280 + void _onReply(BuildContext context) { 281 + HapticFeedback.selectionClick(); 282 + final post = thread.post; 283 + final root = _findRoot(); 284 + 285 + context.push( 286 + '/compose', 287 + extra: { 288 + 'replyParentUri': post.uri.toString(), 289 + 'replyParentCid': post.cid, 290 + 'replyRootUri': root.$1, 291 + 'replyRootCid': root.$2, 292 + 'replyAuthorHandle': post.author.handle, 293 + }, 294 + ); 295 + } 296 + 297 + void _onQuote(BuildContext context) { 298 + HapticFeedback.selectionClick(); 299 + final post = thread.post; 300 + 301 + context.push( 302 + '/compose', 303 + extra: {'quoteUri': post.uri.toString(), 'quoteCid': post.cid, 'quoteAuthorHandle': post.author.handle}, 304 + ); 305 + } 306 + 307 + Future<void> _onToggleSave(BuildContext context) async { 308 + final cubit = context.read<SavedPostsCubit>(); 309 + final post = thread.post; 310 + 311 + await HapticFeedback.lightImpact(); 312 + await cubit.toggleSave(postUri: post.uri.toString(), postJson: jsonEncode(post.toJson())); 313 + } 314 + 315 + void _showMoreOptions(BuildContext context) { 316 + HapticFeedback.mediumImpact(); 317 + final post = thread.post; 318 + final postUri = post.uri.toString(); 319 + final bskyUrl = _convertAtUriToBskyUrl(postUri); 320 + 321 + showModalBottomSheet<void>( 322 + context: context, 323 + builder: (sheetContext) => SafeArea( 324 + child: Column( 325 + mainAxisSize: MainAxisSize.min, 326 + children: [ 327 + ListTile( 328 + leading: const Icon(Icons.copy), 329 + title: const Text('Copy Link'), 330 + onTap: () { 331 + Navigator.pop(sheetContext); 332 + _copyToClipboard(context, bskyUrl); 333 + }, 334 + ), 335 + ListTile( 336 + leading: const Icon(Icons.person_outline), 337 + title: Text('View @${post.author.handle}'), 338 + onTap: () { 339 + Navigator.pop(sheetContext); 340 + context.push('/profile/view?actor=${Uri.encodeQueryComponent(post.author.did)}'); 341 + }, 342 + ), 343 + ListTile( 344 + leading: const Icon(Icons.report_outlined, color: Colors.orange), 345 + title: const Text('Report Post', style: TextStyle(color: Colors.orange)), 346 + onTap: () { 347 + Navigator.pop(sheetContext); 348 + _showReportDialog(context); 349 + }, 350 + ), 351 + if (post.author.did == accountDid) 352 + ListTile( 353 + leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 354 + title: Text('Delete Post', style: TextStyle(color: Theme.of(context).colorScheme.error)), 355 + onTap: () { 356 + Navigator.pop(sheetContext); 357 + _confirmDelete(context); 358 + }, 359 + ), 360 + ], 361 + ), 362 + ), 363 + ); 364 + } 365 + 366 + void _showReportDialog(BuildContext context) { 367 + final post = thread.post; 368 + 369 + showDialog<void>( 370 + context: context, 371 + builder: (dialogContext) => BlocProvider( 372 + create: (_) => ProfileActionCubit( 373 + profileActionRepository: context.read<ProfileActionRepository>(), 374 + actorDid: post.author.did, 375 + ), 376 + child: ReportDialog.post(postUri: post.uri, cid: post.cid, authorHandle: post.author.handle), 377 + ), 378 + ); 379 + } 380 + 381 + void _confirmDelete(BuildContext context) { 382 + showDialog<void>( 383 + context: context, 384 + builder: (dialogContext) => AlertDialog( 385 + title: const Text('Delete Post?'), 386 + content: const Text('This action cannot be undone.'), 387 + actions: [ 388 + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), 389 + FilledButton( 390 + onPressed: () { 391 + Navigator.pop(dialogContext); 392 + context.read<PostActionCubit>().deletePost(); 393 + }, 394 + style: FilledButton.styleFrom( 395 + backgroundColor: Theme.of(context).colorScheme.error, 396 + foregroundColor: Theme.of(context).colorScheme.onError, 397 + ), 398 + child: const Text('Delete'), 399 + ), 400 + ], 401 + ), 402 + ); 403 + } 404 + 405 + void _copyToClipboard(BuildContext context, String text) { 406 + Clipboard.setData(ClipboardData(text: text)); 407 + ScaffoldMessenger.of( 408 + context, 409 + ).showSnackBar(const SnackBar(content: Text('Link copied to clipboard'), behavior: SnackBarBehavior.floating)); 410 + } 411 + 412 + (String, String) _findRoot() { 413 + var current = thread.parent; 414 + ThreadViewPost? root; 415 + while (current != null && current.isThreadViewPost) { 416 + root = current.threadViewPost!; 417 + current = root.parent; 418 + } 419 + if (root != null) { 420 + return (root.post.uri.toString(), root.post.cid); 421 + } 422 + return (thread.post.uri.toString(), thread.post.cid); 423 + } 424 + 425 + FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 426 + try { 427 + return FeedPostRecord.fromJson(record); 428 + } catch (_) { 429 + return null; 430 + } 431 + } 432 + 433 + String _formatTimestamp(DateTime time) { 434 + return DateFormat('h:mm a · MMM d, yyyy').format(time.toLocal()); 435 + } 436 + 437 + String _convertAtUriToBskyUrl(String atUri) { 438 + try { 439 + final uri = Uri.parse(atUri); 440 + final parts = uri.pathSegments; 441 + if (parts.length >= 2) { 442 + final did = uri.host; 443 + final rkey = parts.last; 444 + return 'https://bsky.app/profile/$did/post/$rkey'; 445 + } 446 + } catch (_) { 447 + log.d('failed to convert atUri to bskyUrl'); 448 + } 449 + return atUri; 450 + } 451 + }
+124
test/features/feed/cubit/post_thread_cubit_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bloc_test/bloc_test.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/feed/cubit/post_thread_cubit.dart'; 7 + import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockPostThreadRepository extends Mock implements PostThreadRepository {} 11 + 12 + void main() { 13 + late MockPostThreadRepository mockRepository; 14 + 15 + setUp(() { 16 + mockRepository = MockPostThreadRepository(); 17 + }); 18 + 19 + const testUri = 'at://did:plc:author/app.bsky.feed.post/abc'; 20 + 21 + final sampleThread = ThreadViewPost( 22 + post: PostView( 23 + uri: const AtUri('at://did:plc:author/app.bsky.feed.post/abc'), 24 + cid: 'cid-123', 25 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 26 + record: { 27 + r'$type': 'app.bsky.feed.post', 28 + 'text': 'Test post', 29 + 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String(), 30 + }, 31 + indexedAt: DateTime.utc(2026, 3, 15), 32 + ), 33 + ); 34 + 35 + group('PostThreadCubit', () { 36 + test('initial state is loading', () { 37 + final cubit = PostThreadCubit(postThreadRepository: mockRepository); 38 + 39 + expect(cubit.state.status, PostThreadStatus.loading); 40 + expect(cubit.state.thread, isNull); 41 + expect(cubit.state.error, isNull); 42 + }); 43 + 44 + blocTest<PostThreadCubit, PostThreadState>( 45 + 'load emits loading then loaded on success', 46 + build: () { 47 + when(() => mockRepository.getPostThread(testUri)).thenAnswer((_) async => sampleThread); 48 + return PostThreadCubit(postThreadRepository: mockRepository); 49 + }, 50 + act: (cubit) => cubit.load(testUri), 51 + expect: () => [ 52 + const PostThreadState(status: PostThreadStatus.loading), 53 + PostThreadState(status: PostThreadStatus.loaded, thread: sampleThread), 54 + ], 55 + ); 56 + 57 + blocTest<PostThreadCubit, PostThreadState>( 58 + 'load emits loading then error on failure', 59 + build: () { 60 + when(() => mockRepository.getPostThread(testUri)).thenThrow(Exception('Network error')); 61 + return PostThreadCubit(postThreadRepository: mockRepository); 62 + }, 63 + act: (cubit) => cubit.load(testUri), 64 + expect: () => [ 65 + const PostThreadState(status: PostThreadStatus.loading), 66 + const PostThreadState(status: PostThreadStatus.error, error: 'Failed to load thread'), 67 + ], 68 + ); 69 + 70 + blocTest<PostThreadCubit, PostThreadState>( 71 + 'load calls repository with correct uri', 72 + build: () { 73 + when(() => mockRepository.getPostThread(any())).thenAnswer((_) async => sampleThread); 74 + return PostThreadCubit(postThreadRepository: mockRepository); 75 + }, 76 + act: (cubit) => cubit.load(testUri), 77 + verify: (_) { 78 + verify(() => mockRepository.getPostThread(testUri)).called(1); 79 + }, 80 + ); 81 + 82 + blocTest<PostThreadCubit, PostThreadState>( 83 + 'calling load again resets to loading', 84 + build: () { 85 + when(() => mockRepository.getPostThread(any())).thenAnswer((_) async => sampleThread); 86 + return PostThreadCubit(postThreadRepository: mockRepository); 87 + }, 88 + act: (cubit) async { 89 + await cubit.load(testUri); 90 + await cubit.load(testUri); 91 + }, 92 + expect: () => [ 93 + const PostThreadState(status: PostThreadStatus.loading), 94 + PostThreadState(status: PostThreadStatus.loaded, thread: sampleThread), 95 + const PostThreadState(status: PostThreadStatus.loading), 96 + PostThreadState(status: PostThreadStatus.loaded, thread: sampleThread), 97 + ], 98 + ); 99 + }); 100 + 101 + group('PostThreadState', () { 102 + test('initial state has loading status', () { 103 + const state = PostThreadState(); 104 + 105 + expect(state.status, PostThreadStatus.loading); 106 + expect(state.thread, isNull); 107 + expect(state.error, isNull); 108 + }); 109 + 110 + test('props includes all fields for equality', () { 111 + final state1 = PostThreadState(status: PostThreadStatus.loaded, thread: sampleThread); 112 + final state2 = PostThreadState(status: PostThreadStatus.loaded, thread: sampleThread); 113 + 114 + expect(state1, equals(state2)); 115 + }); 116 + 117 + test('states with different status are not equal', () { 118 + const state1 = PostThreadState(status: PostThreadStatus.loading); 119 + const state2 = PostThreadState(status: PostThreadStatus.error, error: 'error'); 120 + 121 + expect(state1, isNot(equals(state2))); 122 + }); 123 + }); 124 + }
+127
test/features/feed/data/post_thread_repository_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockPostThreadRepository extends Mock implements PostThreadRepository {} 9 + 10 + void main() { 11 + late MockPostThreadRepository mockRepository; 12 + 13 + setUp(() { 14 + mockRepository = MockPostThreadRepository(); 15 + }); 16 + 17 + final sampleThreadViewPost = ThreadViewPost( 18 + post: PostView( 19 + uri: const AtUri('at://did:plc:author/app.bsky.feed.post/abc'), 20 + cid: 'cid-123', 21 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 22 + record: { 23 + r'$type': 'app.bsky.feed.post', 24 + 'text': 'Hello world', 25 + 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String(), 26 + }, 27 + indexedAt: DateTime.utc(2026, 3, 15), 28 + ), 29 + ); 30 + 31 + group('PostThreadRepository contract', () { 32 + test('getPostThread returns ThreadViewPost on success', () async { 33 + const testUri = 'at://did:plc:author/app.bsky.feed.post/abc'; 34 + 35 + when(() => mockRepository.getPostThread(testUri)).thenAnswer((_) async => sampleThreadViewPost); 36 + 37 + final result = await mockRepository.getPostThread(testUri); 38 + 39 + expect(result.post.uri.toString(), testUri); 40 + expect(result.post.cid, 'cid-123'); 41 + }); 42 + 43 + test('getPostThread returns thread with parent', () async { 44 + const testUri = 'at://did:plc:author/app.bsky.feed.post/abc'; 45 + final parentPost = PostView( 46 + uri: const AtUri('at://did:plc:parent/app.bsky.feed.post/root'), 47 + cid: 'cid-root', 48 + author: const ProfileViewBasic(did: 'did:plc:parent', handle: 'parent.bsky.social'), 49 + record: { 50 + r'$type': 'app.bsky.feed.post', 51 + 'text': 'Root post', 52 + 'createdAt': DateTime.utc(2026, 3, 14).toIso8601String(), 53 + }, 54 + indexedAt: DateTime.utc(2026, 3, 14), 55 + ); 56 + final threadWithParent = ThreadViewPost( 57 + post: sampleThreadViewPost.post, 58 + parent: UThreadViewPostParent.threadViewPost(data: ThreadViewPost(post: parentPost)), 59 + ); 60 + 61 + when(() => mockRepository.getPostThread(testUri)).thenAnswer((_) async => threadWithParent); 62 + 63 + final result = await mockRepository.getPostThread(testUri); 64 + 65 + expect(result.parent, isNotNull); 66 + expect(result.parent!.isThreadViewPost, isTrue); 67 + expect(result.parent!.threadViewPost!.post.cid, 'cid-root'); 68 + }); 69 + 70 + test('getPostThread returns thread with replies', () async { 71 + const testUri = 'at://did:plc:author/app.bsky.feed.post/abc'; 72 + final replyPost = PostView( 73 + uri: const AtUri('at://did:plc:reply/app.bsky.feed.post/reply1'), 74 + cid: 'cid-reply', 75 + author: const ProfileViewBasic(did: 'did:plc:reply', handle: 'reply.bsky.social'), 76 + record: { 77 + r'$type': 'app.bsky.feed.post', 78 + 'text': 'Reply post', 79 + 'createdAt': DateTime.utc(2026, 3, 15, 1).toIso8601String(), 80 + }, 81 + indexedAt: DateTime.utc(2026, 3, 15, 1), 82 + ); 83 + final threadWithReplies = ThreadViewPost( 84 + post: sampleThreadViewPost.post, 85 + replies: [UThreadViewPostReplies.threadViewPost(data: ThreadViewPost(post: replyPost))], 86 + ); 87 + 88 + when(() => mockRepository.getPostThread(testUri)).thenAnswer((_) async => threadWithReplies); 89 + 90 + final result = await mockRepository.getPostThread(testUri); 91 + 92 + expect(result.replies, isNotNull); 93 + expect(result.replies!.length, 1); 94 + expect(result.replies!.first.isThreadViewPost, isTrue); 95 + }); 96 + 97 + test('getPostThread throws when post not found', () async { 98 + const testUri = 'at://did:plc:author/app.bsky.feed.post/missing'; 99 + 100 + when(() => mockRepository.getPostThread(testUri)).thenThrow(Exception('Post not found')); 101 + 102 + expect(() => mockRepository.getPostThread(testUri), throwsException); 103 + }); 104 + 105 + test('getPostThread throws when post is blocked', () async { 106 + const testUri = 'at://did:plc:blocked/app.bsky.feed.post/abc'; 107 + 108 + when(() => mockRepository.getPostThread(testUri)).thenThrow(Exception('Post is from a blocked account')); 109 + 110 + expect(() => mockRepository.getPostThread(testUri), throwsException); 111 + }); 112 + }); 113 + 114 + group('PostThreadRepository implementation', () { 115 + test('getPostThread with no parent returns thread without parent', () async { 116 + const testUri = 'at://did:plc:author/app.bsky.feed.post/abc'; 117 + final threadNoParent = ThreadViewPost(post: sampleThreadViewPost.post); 118 + 119 + when(() => mockRepository.getPostThread(testUri)).thenAnswer((_) async => threadNoParent); 120 + 121 + final result = await mockRepository.getPostThread(testUri); 122 + 123 + expect(result.parent, isNull); 124 + expect(result.replies, isNull); 125 + }); 126 + }); 127 + }
+365
test/features/feed/presentation/post_thread_screen_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bloc_test/bloc_test.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:flutter_bloc/flutter_bloc.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 9 + import 'package:lazurite/features/feed/cubit/post_thread_cubit.dart'; 10 + import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 11 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 12 + import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 13 + import 'package:mocktail/mocktail.dart'; 14 + 15 + class MockPostThreadCubit extends MockCubit<PostThreadState> implements PostThreadCubit {} 16 + 17 + class MockPostThreadRepository extends Mock implements PostThreadRepository {} 18 + 19 + class MockPostActionRepository extends Mock implements PostActionRepository {} 20 + 21 + class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} 22 + 23 + PostView _makePost({ 24 + String did = 'did:plc:author', 25 + String handle = 'author.bsky.social', 26 + String rkey = 'abc', 27 + String text = 'Hello world', 28 + int? replyCount, 29 + int? repostCount, 30 + int? likeCount, 31 + }) { 32 + return PostView( 33 + uri: AtUri('at://$did/app.bsky.feed.post/$rkey'), 34 + cid: 'cid-$rkey', 35 + author: ProfileViewBasic(did: did, handle: handle), 36 + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String()}, 37 + indexedAt: DateTime.utc(2026, 3, 15), 38 + replyCount: replyCount, 39 + repostCount: repostCount, 40 + likeCount: likeCount, 41 + ); 42 + } 43 + 44 + void main() { 45 + late MockPostThreadCubit mockCubit; 46 + late MockSavedPostsCubit mockSavedPostsCubit; 47 + late MockPostActionRepository mockPostActionRepository; 48 + 49 + setUpAll(() { 50 + registerFallbackValue(AtUri.parse('at://did:plc:test/app.bsky.feed.post/fallback')); 51 + }); 52 + 53 + setUp(() { 54 + mockCubit = MockPostThreadCubit(); 55 + mockSavedPostsCubit = MockSavedPostsCubit(); 56 + mockPostActionRepository = MockPostActionRepository(); 57 + }); 58 + 59 + Widget buildSubject({PostThreadState? state}) { 60 + when(() => mockCubit.state).thenReturn(state ?? const PostThreadState(status: PostThreadStatus.loading)); 61 + when( 62 + () => mockSavedPostsCubit.state, 63 + ).thenReturn(const SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {})); 64 + 65 + return MaterialApp( 66 + home: MultiRepositoryProvider( 67 + providers: [ 68 + RepositoryProvider<PostActionRepository>.value(value: mockPostActionRepository), 69 + RepositoryProvider<String>.value(value: 'did:plc:currentuser'), 70 + ], 71 + child: MultiBlocProvider( 72 + providers: [ 73 + BlocProvider<PostThreadCubit>.value(value: mockCubit), 74 + BlocProvider<SavedPostsCubit>.value(value: mockSavedPostsCubit), 75 + ], 76 + child: Scaffold( 77 + body: BlocBuilder<PostThreadCubit, PostThreadState>( 78 + builder: (context, cubitState) { 79 + return switch (cubitState.status) { 80 + PostThreadStatus.loading => const Center(child: CircularProgressIndicator()), 81 + PostThreadStatus.error => Center( 82 + child: Column( 83 + mainAxisAlignment: MainAxisAlignment.center, 84 + children: [ 85 + const Icon(Icons.error_outline), 86 + Text(cubitState.error ?? 'Failed to load thread'), 87 + FilledButton(onPressed: () => mockCubit.load('test'), child: const Text('Retry')), 88 + ], 89 + ), 90 + ), 91 + PostThreadStatus.loaded => const Text('Thread loaded'), 92 + }; 93 + }, 94 + ), 95 + ), 96 + ), 97 + ), 98 + ); 99 + } 100 + 101 + group('PostThreadScreen states', () { 102 + testWidgets('shows loading indicator when status is loading', (tester) async { 103 + await tester.pumpWidget(buildSubject(state: const PostThreadState(status: PostThreadStatus.loading))); 104 + 105 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 106 + }); 107 + 108 + testWidgets('shows error message when status is error', (tester) async { 109 + await tester.pumpWidget( 110 + buildSubject( 111 + state: const PostThreadState(status: PostThreadStatus.error, error: 'Failed to load thread'), 112 + ), 113 + ); 114 + 115 + expect(find.text('Failed to load thread'), findsOneWidget); 116 + expect(find.byType(FilledButton), findsOneWidget); 117 + }); 118 + 119 + testWidgets('shows thread loaded text when status is loaded', (tester) async { 120 + final thread = ThreadViewPost(post: _makePost()); 121 + await tester.pumpWidget( 122 + buildSubject( 123 + state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), 124 + ), 125 + ); 126 + 127 + expect(find.text('Thread loaded'), findsOneWidget); 128 + }); 129 + }); 130 + 131 + group('PostThreadScreen full render', () { 132 + Widget buildFullScreen({required PostThreadState state}) { 133 + when(() => mockCubit.state).thenReturn(state); 134 + when( 135 + () => mockSavedPostsCubit.state, 136 + ).thenReturn(const SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {})); 137 + 138 + when( 139 + () => mockPostActionRepository.likePost( 140 + uri: any(named: 'uri'), 141 + cid: any(named: 'cid'), 142 + ), 143 + ).thenAnswer((_) async => 'at://did:plc:test/app.bsky.feed.like/like1'); 144 + 145 + return MaterialApp( 146 + home: MultiRepositoryProvider( 147 + providers: [ 148 + RepositoryProvider<PostActionRepository>.value(value: mockPostActionRepository), 149 + RepositoryProvider<String>.value(value: 'did:plc:currentuser'), 150 + ], 151 + child: MultiBlocProvider( 152 + providers: [ 153 + BlocProvider<PostThreadCubit>.value(value: mockCubit), 154 + BlocProvider<SavedPostsCubit>.value(value: mockSavedPostsCubit), 155 + ], 156 + child: Scaffold( 157 + appBar: AppBar(title: const Text('Thread')), 158 + body: Builder( 159 + builder: (context) { 160 + if (state.status == PostThreadStatus.loaded) { 161 + final post = state.thread!.post; 162 + return BlocProvider( 163 + create: (_) => PostActionCubit( 164 + postActionRepository: mockPostActionRepository, 165 + postUri: post.uri.toString(), 166 + postCid: post.cid, 167 + ), 168 + child: SingleChildScrollView( 169 + child: Column( 170 + crossAxisAlignment: CrossAxisAlignment.start, 171 + children: [ 172 + Text(post.author.displayName ?? post.author.handle), 173 + Text((post.record['text'] as String?) ?? ''), 174 + if ((post.replyCount ?? 0) > 0) Text('${post.replyCount} replies'), 175 + if ((post.repostCount ?? 0) > 0) Text('${post.repostCount} reposts'), 176 + if ((post.likeCount ?? 0) > 0) Text('${post.likeCount} likes'), 177 + ], 178 + ), 179 + ), 180 + ); 181 + } 182 + return const SizedBox.shrink(); 183 + }, 184 + ), 185 + ), 186 + ), 187 + ), 188 + ); 189 + } 190 + 191 + testWidgets('focused post shows author name', (tester) async { 192 + final thread = ThreadViewPost( 193 + post: _makePost(handle: 'alice.bsky.social', text: 'My focused post'), 194 + ); 195 + 196 + await tester.pumpWidget( 197 + buildFullScreen( 198 + state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), 199 + ), 200 + ); 201 + 202 + expect(find.text('alice.bsky.social'), findsOneWidget); 203 + expect(find.text('My focused post'), findsOneWidget); 204 + }); 205 + 206 + testWidgets('focused post shows stats when counts are non-zero', (tester) async { 207 + final thread = ThreadViewPost(post: _makePost(replyCount: 24, repostCount: 12, likeCount: 156)); 208 + 209 + await tester.pumpWidget( 210 + buildFullScreen( 211 + state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), 212 + ), 213 + ); 214 + 215 + expect(find.text('24 replies'), findsOneWidget); 216 + expect(find.text('12 reposts'), findsOneWidget); 217 + expect(find.text('156 likes'), findsOneWidget); 218 + }); 219 + 220 + testWidgets('focused post does not show stats when counts are zero', (tester) async { 221 + final thread = ThreadViewPost(post: _makePost(replyCount: 0, repostCount: 0, likeCount: 0)); 222 + 223 + await tester.pumpWidget( 224 + buildFullScreen( 225 + state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), 226 + ), 227 + ); 228 + 229 + expect(find.text('0 replies'), findsNothing); 230 + expect(find.text('0 reposts'), findsNothing); 231 + expect(find.text('0 likes'), findsNothing); 232 + }); 233 + }); 234 + 235 + group('PostThreadScreen thread structure', () { 236 + testWidgets('renders thread app bar title', (tester) async { 237 + when(() => mockCubit.state).thenReturn(const PostThreadState(status: PostThreadStatus.loading)); 238 + when( 239 + () => mockSavedPostsCubit.state, 240 + ).thenReturn(const SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {})); 241 + 242 + await tester.pumpWidget( 243 + MaterialApp( 244 + home: MultiRepositoryProvider( 245 + providers: [ 246 + RepositoryProvider<PostActionRepository>.value(value: mockPostActionRepository), 247 + RepositoryProvider<String>.value(value: 'did:plc:currentuser'), 248 + ], 249 + child: MultiBlocProvider( 250 + providers: [ 251 + BlocProvider<PostThreadCubit>.value(value: mockCubit), 252 + BlocProvider<SavedPostsCubit>.value(value: mockSavedPostsCubit), 253 + ], 254 + child: Scaffold(appBar: AppBar(title: const Text('Thread'))), 255 + ), 256 + ), 257 + ), 258 + ); 259 + 260 + expect(find.text('Thread'), findsOneWidget); 261 + }); 262 + }); 263 + 264 + group('PostThreadState parent chain', () { 265 + test('getParentChain returns empty list for root post', () { 266 + final thread = ThreadViewPost(post: _makePost()); 267 + final parents = _extractParentChain(thread); 268 + 269 + expect(parents, isEmpty); 270 + }); 271 + 272 + test('getParentChain returns single parent in order', () { 273 + final parentPost = _makePost(rkey: 'parent1', text: 'Parent post'); 274 + final childPost = _makePost(rkey: 'child1', text: 'Child post'); 275 + final parentThread = ThreadViewPost(post: parentPost); 276 + final thread = ThreadViewPost( 277 + post: childPost, 278 + parent: UThreadViewPostParent.threadViewPost(data: parentThread), 279 + ); 280 + 281 + final parents = _extractParentChain(thread); 282 + 283 + expect(parents.length, 1); 284 + expect(parents.first.post.cid, 'cid-parent1'); 285 + }); 286 + 287 + test('getParentChain returns chain in oldest-first order', () { 288 + final grandparentPost = _makePost(rkey: 'gp', text: 'Grandparent'); 289 + final parentPost = _makePost(rkey: 'p', text: 'Parent'); 290 + final childPost = _makePost(rkey: 'c', text: 'Child'); 291 + 292 + final grandparentThread = ThreadViewPost(post: grandparentPost); 293 + final parentThread = ThreadViewPost( 294 + post: parentPost, 295 + parent: UThreadViewPostParent.threadViewPost(data: grandparentThread), 296 + ); 297 + final thread = ThreadViewPost( 298 + post: childPost, 299 + parent: UThreadViewPostParent.threadViewPost(data: parentThread), 300 + ); 301 + 302 + final parents = _extractParentChain(thread); 303 + 304 + expect(parents.length, 2); 305 + expect(parents[0].post.cid, 'cid-gp'); // oldest first 306 + expect(parents[1].post.cid, 'cid-p'); 307 + }); 308 + 309 + test('getParentChain stops at non-thread-view parent', () { 310 + final childPost = _makePost(rkey: 'c', text: 'Child'); 311 + final thread = ThreadViewPost( 312 + post: childPost, 313 + parent: const UThreadViewPostParent.notFoundPost(data: NotFoundPost(uri: AtUri('at://x/y/z'), notFound: true)), 314 + ); 315 + 316 + final parents = _extractParentChain(thread); 317 + 318 + expect(parents, isEmpty); 319 + }); 320 + }); 321 + 322 + group('PostThreadState replies filtering', () { 323 + test('filters out non-thread-view replies', () { 324 + final mainPost = _makePost(rkey: 'main'); 325 + final replyPost = _makePost(rkey: 'reply1', text: 'A reply'); 326 + final thread = ThreadViewPost( 327 + post: mainPost, 328 + replies: [ 329 + UThreadViewPostReplies.threadViewPost(data: ThreadViewPost(post: replyPost)), 330 + const UThreadViewPostReplies.notFoundPost(data: NotFoundPost(uri: AtUri('at://x/y/z'), notFound: true)), 331 + ], 332 + ); 333 + 334 + final replies = _extractThreadReplies(thread); 335 + 336 + expect(replies.length, 1); 337 + expect(replies.first.post.cid, 'cid-reply1'); 338 + }); 339 + 340 + test('returns empty list when no replies', () { 341 + final thread = ThreadViewPost(post: _makePost()); 342 + 343 + final replies = _extractThreadReplies(thread); 344 + 345 + expect(replies, isEmpty); 346 + }); 347 + }); 348 + } 349 + 350 + /// Mirrors _PostThreadContent._getParentChain for unit testing. 351 + List<ThreadViewPost> _extractParentChain(ThreadViewPost thread) { 352 + final parents = <ThreadViewPost>[]; 353 + var current = thread.parent; 354 + while (current != null && current.isThreadViewPost) { 355 + final parentThread = current.threadViewPost!; 356 + parents.add(parentThread); 357 + current = parentThread.parent; 358 + } 359 + return parents.reversed.toList(); 360 + } 361 + 362 + /// Mirrors the reply extraction in _PostThreadContent._buildThread. 363 + List<ThreadViewPost> _extractThreadReplies(ThreadViewPost thread) { 364 + return (thread.replies ?? []).where((r) => r.isThreadViewPost).map((r) => r.threadViewPost!).toList(); 365 + }