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: collapsible thread view with auto-collapse depth setting

+1074 -336
+13 -13
docs/tasks/ui-refactor.md
··· 56 56 57 57 ## M6 — Collapsible Threaded Replies 58 58 59 - - [ ] Recursive `ThreadReplyNode` widget that renders nested replies from `ThreadViewPost.replies` 60 - - [ ] Indentation with cumulative `24px` left padding per depth level 61 - - [ ] Color-coded vertical threadlines (cycle palette of 6 muted theme-derived colors) 62 - - [ ] Tap-threadline-to-collapse interaction with `24dp` touch target 63 - - [ ] Long-press-to-collapse as secondary affordance 64 - - [ ] Collapsed state: header visible, body/children hidden, "N replies hidden" indicator 65 - - [ ] `AnimatedSize` / `AnimatedCrossFade` collapse transition (`200ms`) 66 - - [ ] Depth cap at 6 with "Continue this thread →" navigation link 67 - - [ ] Local collapse state via `Set<String>` of post URIs in screen `State` 68 - - [ ] `thread_auto_collapse_depth` setting in Drift + Drift migration 69 - - [ ] Expose auto-collapse depth in Layout Settings screen 70 - - [ ] Never auto-collapse OP replies 71 - - [ ] Tests for thread tree rendering, collapse/expand, depth cap, and auto-collapse behavior 59 + - [x] Recursive `ThreadReplyNode` widget that renders nested replies from `ThreadViewPost.replies` 60 + - [x] Indentation with cumulative `24px` left padding per depth level 61 + - [x] Color-coded vertical threadlines (cycle palette of 6 muted theme-derived colors) 62 + - [x] Tap-threadline-to-collapse interaction with `24dp` touch target 63 + - [x] Long-press-to-collapse as secondary affordance 64 + - [x] Collapsed state: header visible, body/children hidden, "N replies hidden" indicator 65 + - [x] `AnimatedSize` / `AnimatedCrossFade` collapse transition (`200ms`) 66 + - [x] Depth cap at 6 with "Continue this thread →" navigation link 67 + - [x] Local collapse state via `Set<String>` of post URIs in screen `State` 68 + - [x] `thread_auto_collapse_depth` setting in Drift + Drift migration 69 + - [x] Expose auto-collapse depth in Layout Settings screen 70 + - [x] Never auto-collapse OP replies 71 + - [x] Tests for thread tree rendering, collapse/expand, depth cap, and auto-collapse behavior
+7 -1
lib/core/database/app_database.dart
··· 23 23 AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); 24 24 25 25 @override 26 - int get schemaVersion => 10; 26 + int get schemaVersion => 11; 27 27 28 28 @override 29 29 MigrationStrategy get migration => MigrationStrategy( ··· 63 63 await customStatement( 64 64 "INSERT OR IGNORE INTO settings (key, value) VALUES ('ui_density', 'standard'), ('feed_architecture', 'grid')", 65 65 ); 66 + } 67 + if (from < 11) { 68 + /* 69 + The thread auto-collapse setting is nullable and represented by 70 + the presence or absence of a row in the existing settings table. 71 + */ 66 72 } 67 73 }, 68 74 );
+485 -24
lib/features/feed/presentation/post_thread_screen.dart
··· 21 21 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 22 22 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 23 23 import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 24 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 24 25 25 26 class PostThreadScreen extends StatelessWidget { 26 27 const PostThreadScreen({super.key, required this.postUri}); ··· 37 38 } 38 39 } 39 40 40 - class _PostThreadContent extends StatelessWidget { 41 + const int _maxThreadDepth = 6; 42 + const double _threadIndentPerDepth = 24; 43 + const double _threadLineTouchTarget = 24; 44 + const Duration _threadCollapseDuration = Duration(milliseconds: 200); 45 + 46 + Set<String> computeInitialCollapsedThreadUris(ThreadViewPost thread, {required int? autoCollapseDepth}) { 47 + if (autoCollapseDepth == null) { 48 + return <String>{}; 49 + } 50 + 51 + final opDid = _getThreadRoot(thread).post.author.did; 52 + final collapsedUris = <String>{}; 53 + 54 + void visit(ThreadViewPost node, int depth) { 55 + for (final reply in _threadRepliesOf(node)) { 56 + final childDepth = depth + 1; 57 + final childReplies = _threadRepliesOf(reply); 58 + if (childDepth > autoCollapseDepth && childReplies.isNotEmpty && reply.post.author.did != opDid) { 59 + collapsedUris.add(reply.post.uri.toString()); 60 + } 61 + visit(reply, childDepth); 62 + } 63 + } 64 + 65 + visit(thread, 0); 66 + return collapsedUris; 67 + } 68 + 69 + class _PostThreadContent extends StatefulWidget { 41 70 const _PostThreadContent({required this.postUri}); 42 71 43 72 final String postUri; 44 73 45 74 @override 75 + State<_PostThreadContent> createState() => _PostThreadContentState(); 76 + } 77 + 78 + class _PostThreadContentState extends State<_PostThreadContent> { 79 + Set<String> _collapsedUris = <String>{}; 80 + String? _initializedThreadUri; 81 + 82 + @override 83 + void didChangeDependencies() { 84 + super.didChangeDependencies(); 85 + final state = context.read<PostThreadCubit>().state; 86 + if (state.status == PostThreadStatus.loaded && state.thread != null) { 87 + _syncInitialCollapsedUris(state.thread!); 88 + } 89 + } 90 + 91 + void _syncInitialCollapsedUris(ThreadViewPost thread) { 92 + final threadUri = thread.post.uri.toString(); 93 + if (_initializedThreadUri == threadUri) { 94 + return; 95 + } 96 + 97 + final collapsedUris = computeInitialCollapsedThreadUris( 98 + thread, 99 + autoCollapseDepth: context.read<SettingsCubit>().state.threadAutoCollapseDepth, 100 + ); 101 + 102 + setState(() { 103 + _initializedThreadUri = threadUri; 104 + _collapsedUris = collapsedUris; 105 + }); 106 + } 107 + 108 + void _toggleCollapsed(String postUri) { 109 + setState(() { 110 + if (_collapsedUris.contains(postUri)) { 111 + _collapsedUris.remove(postUri); 112 + } else { 113 + _collapsedUris.add(postUri); 114 + } 115 + }); 116 + } 117 + 118 + @override 46 119 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 - }, 120 + return BlocListener<PostThreadCubit, PostThreadState>( 121 + listenWhen: (previous, current) { 122 + if (current.status != PostThreadStatus.loaded || current.thread == null) { 123 + return false; 124 + } 125 + return previous.thread?.post.uri.toString() != current.thread!.post.uri.toString(); 126 + }, 127 + listener: (context, state) { 128 + _syncInitialCollapsedUris(state.thread!); 129 + }, 130 + child: Scaffold( 131 + appBar: AppBar(title: const Text('Thread')), 132 + body: BlocBuilder<PostThreadCubit, PostThreadState>( 133 + builder: (context, state) { 134 + return switch (state.status) { 135 + PostThreadStatus.loading => const Center(child: CircularProgressIndicator()), 136 + PostThreadStatus.error => _buildError(context, state.error ?? 'Failed to load thread'), 137 + PostThreadStatus.loaded => _buildThread(context, state.thread!), 138 + }; 139 + }, 140 + ), 57 141 ), 58 142 ); 59 143 } ··· 67 151 const SizedBox(height: 16), 68 152 Text(message), 69 153 const SizedBox(height: 16), 70 - FilledButton(onPressed: () => context.read<PostThreadCubit>().load(postUri), child: const Text('Retry')), 154 + FilledButton( 155 + onPressed: () => context.read<PostThreadCubit>().load(widget.postUri), 156 + child: const Text('Retry'), 157 + ), 71 158 ], 72 159 ), 73 160 ); ··· 76 163 Widget _buildThread(BuildContext context, ThreadViewPost thread) { 77 164 final accountDid = context.read<String>(); 78 165 final parents = _getParentChain(thread); 79 - final replies = (thread.replies ?? []).where((r) => r.isThreadViewPost).map((r) => r.threadViewPost!).toList(); 166 + final replies = _threadRepliesOf(thread); 167 + final opDid = (parents.isNotEmpty ? parents.first : thread).post.author.did; 80 168 81 169 return ListView( 82 170 children: [ ··· 102 190 ), 103 191 const Divider(height: 1), 104 192 for (final reply in replies) 105 - PostCardWithActions( 106 - feedViewPost: FeedViewPost(post: reply.post), 193 + ThreadReplyNode( 194 + key: ValueKey('thread-reply-node-${reply.post.uri}'), 195 + thread: reply, 196 + depth: 1, 107 197 accountDid: accountDid, 198 + opDid: opDid, 199 + collapsedUris: _collapsedUris, 200 + onToggleCollapse: _toggleCollapsed, 108 201 ), 109 202 ], 110 203 ], ··· 135 228 } 136 229 } 137 230 231 + class ThreadReplyNode extends StatelessWidget { 232 + const ThreadReplyNode({ 233 + super.key, 234 + required this.thread, 235 + required this.depth, 236 + required this.accountDid, 237 + required this.opDid, 238 + required this.collapsedUris, 239 + required this.onToggleCollapse, 240 + this.onContinueThread, 241 + }); 242 + 243 + final ThreadViewPost thread; 244 + final int depth; 245 + final String accountDid; 246 + final String opDid; 247 + final Set<String> collapsedUris; 248 + final ValueChanged<String> onToggleCollapse; 249 + final ValueChanged<ThreadViewPost>? onContinueThread; 250 + 251 + @override 252 + Widget build(BuildContext context) { 253 + if (depth > _maxThreadDepth) { 254 + return _ThreadOverflowLink(thread: thread, depth: depth, onContinueThread: onContinueThread); 255 + } 256 + 257 + final postUri = thread.post.uri.toString(); 258 + final replies = _threadRepliesOf(thread); 259 + final isCollapsed = collapsedUris.contains(postUri); 260 + final lineColor = _threadLineColors(context)[(depth - 1) % _threadLineColors(context).length]; 261 + final indent = (depth - 1) * _threadIndentPerDepth; 262 + final canCollapse = replies.isNotEmpty; 263 + 264 + return Padding( 265 + padding: EdgeInsets.only(left: indent), 266 + child: Stack( 267 + children: [ 268 + Padding( 269 + padding: const EdgeInsets.only(left: _threadLineTouchTarget), 270 + child: AnimatedSize( 271 + duration: _threadCollapseDuration, 272 + curve: Curves.easeInOut, 273 + child: isCollapsed 274 + ? _CollapsedThreadReply( 275 + thread: thread, 276 + hiddenReplyCount: _countDescendantReplies(thread), 277 + onLongPress: canCollapse ? () => onToggleCollapse(postUri) : null, 278 + ) 279 + : _ExpandedThreadReply( 280 + thread: thread, 281 + depth: depth, 282 + accountDid: accountDid, 283 + opDid: opDid, 284 + collapsedUris: collapsedUris, 285 + onToggleCollapse: onToggleCollapse, 286 + onContinueThread: onContinueThread, 287 + ), 288 + ), 289 + ), 290 + Positioned( 291 + left: 0, 292 + top: 0, 293 + bottom: 0, 294 + width: _threadLineTouchTarget, 295 + child: canCollapse 296 + ? _ThreadLineButton( 297 + color: lineColor, 298 + postUri: postUri, 299 + isCollapsed: isCollapsed, 300 + onTap: () => onToggleCollapse(postUri), 301 + ) 302 + : IgnorePointer(child: _ThreadLine(color: lineColor)), 303 + ), 304 + ], 305 + ), 306 + ); 307 + } 308 + } 309 + 310 + class _ExpandedThreadReply extends StatelessWidget { 311 + const _ExpandedThreadReply({ 312 + required this.thread, 313 + required this.depth, 314 + required this.accountDid, 315 + required this.opDid, 316 + required this.collapsedUris, 317 + required this.onToggleCollapse, 318 + this.onContinueThread, 319 + }); 320 + 321 + final ThreadViewPost thread; 322 + final int depth; 323 + final String accountDid; 324 + final String opDid; 325 + final Set<String> collapsedUris; 326 + final ValueChanged<String> onToggleCollapse; 327 + final ValueChanged<ThreadViewPost>? onContinueThread; 328 + 329 + @override 330 + Widget build(BuildContext context) { 331 + final postUri = thread.post.uri.toString(); 332 + final replies = _threadRepliesOf(thread); 333 + 334 + return GestureDetector( 335 + behavior: HitTestBehavior.opaque, 336 + onLongPress: replies.isNotEmpty ? () => onToggleCollapse(postUri) : null, 337 + child: Column( 338 + crossAxisAlignment: CrossAxisAlignment.start, 339 + children: [ 340 + PostCardWithActions( 341 + feedViewPost: FeedViewPost(post: thread.post), 342 + accountDid: accountDid, 343 + ), 344 + for (final reply in replies) 345 + ThreadReplyNode( 346 + key: ValueKey('thread-reply-node-${reply.post.uri}'), 347 + thread: reply, 348 + depth: depth + 1, 349 + accountDid: accountDid, 350 + opDid: opDid, 351 + collapsedUris: collapsedUris, 352 + onToggleCollapse: onToggleCollapse, 353 + onContinueThread: onContinueThread, 354 + ), 355 + ], 356 + ), 357 + ); 358 + } 359 + } 360 + 361 + class _CollapsedThreadReply extends StatelessWidget { 362 + const _CollapsedThreadReply({required this.thread, required this.hiddenReplyCount, this.onLongPress}); 363 + 364 + final ThreadViewPost thread; 365 + final int hiddenReplyCount; 366 + final VoidCallback? onLongPress; 367 + 368 + @override 369 + Widget build(BuildContext context) { 370 + final post = thread.post; 371 + final colorScheme = Theme.of(context).colorScheme; 372 + 373 + return GestureDetector( 374 + behavior: HitTestBehavior.opaque, 375 + onLongPress: onLongPress, 376 + child: Container( 377 + margin: const EdgeInsets.symmetric(vertical: 1), 378 + decoration: BoxDecoration( 379 + border: Border.all(color: colorScheme.outlineVariant), 380 + color: colorScheme.surfaceContainerLowest, 381 + ), 382 + child: Padding( 383 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), 384 + child: Column( 385 + crossAxisAlignment: CrossAxisAlignment.start, 386 + children: [ 387 + _CollapsedThreadHeader(post: post), 388 + const SizedBox(height: 10), 389 + Text( 390 + _hiddenReplyLabel(hiddenReplyCount).toUpperCase(), 391 + key: ValueKey('collapsed-indicator-${post.uri}'), 392 + style: Theme.of( 393 + context, 394 + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, letterSpacing: 1.1), 395 + ), 396 + ], 397 + ), 398 + ), 399 + ), 400 + ); 401 + } 402 + } 403 + 404 + class _CollapsedThreadHeader extends StatelessWidget { 405 + const _CollapsedThreadHeader({required this.post}); 406 + 407 + final PostView post; 408 + 409 + @override 410 + Widget build(BuildContext context) { 411 + final colorScheme = Theme.of(context).colorScheme; 412 + final timestamp = _parsePostRecord(post.record)?.createdAt ?? post.indexedAt; 413 + 414 + return Row( 415 + crossAxisAlignment: CrossAxisAlignment.start, 416 + children: [ 417 + Container( 418 + width: 40, 419 + height: 40, 420 + decoration: BoxDecoration( 421 + color: colorScheme.surfaceContainerHighest, 422 + border: Border.all(color: colorScheme.outlineVariant), 423 + ), 424 + child: post.author.avatar != null 425 + ? Image.network(post.author.avatar!, fit: BoxFit.cover) 426 + : Center( 427 + child: Text( 428 + _initials(post.author.displayName ?? post.author.handle), 429 + style: Theme.of(context).textTheme.labelLarge, 430 + ), 431 + ), 432 + ), 433 + const SizedBox(width: 12), 434 + Expanded( 435 + child: Column( 436 + crossAxisAlignment: CrossAxisAlignment.start, 437 + children: [ 438 + Row( 439 + children: [ 440 + Expanded( 441 + child: Text( 442 + post.author.displayName ?? post.author.handle, 443 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 444 + maxLines: 1, 445 + overflow: TextOverflow.ellipsis, 446 + ), 447 + ), 448 + const SizedBox(width: 8), 449 + Text( 450 + DateFormat('MMM d').format(timestamp.toLocal()).toUpperCase(), 451 + style: Theme.of( 452 + context, 453 + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, letterSpacing: 0.8), 454 + ), 455 + ], 456 + ), 457 + const SizedBox(height: 2), 458 + Text( 459 + '@${post.author.handle}'.toUpperCase(), 460 + style: Theme.of(context).textTheme.labelSmall?.copyWith( 461 + color: colorScheme.onSurfaceVariant, 462 + fontWeight: FontWeight.w700, 463 + letterSpacing: 1.5, 464 + ), 465 + maxLines: 1, 466 + overflow: TextOverflow.ellipsis, 467 + ), 468 + ], 469 + ), 470 + ), 471 + ], 472 + ); 473 + } 474 + } 475 + 476 + class _ThreadOverflowLink extends StatelessWidget { 477 + const _ThreadOverflowLink({required this.thread, required this.depth, this.onContinueThread}); 478 + 479 + final ThreadViewPost thread; 480 + final int depth; 481 + final ValueChanged<ThreadViewPost>? onContinueThread; 482 + 483 + @override 484 + Widget build(BuildContext context) { 485 + return Padding( 486 + padding: const EdgeInsets.only(left: _maxThreadDepth * _threadIndentPerDepth), 487 + child: Align( 488 + alignment: Alignment.centerLeft, 489 + child: TextButton( 490 + key: ValueKey('continue-thread-${thread.post.uri}'), 491 + onPressed: () { 492 + if (onContinueThread != null) { 493 + onContinueThread!(thread); 494 + return; 495 + } 496 + context.push('/post?uri=${Uri.encodeQueryComponent(thread.post.uri.toString())}'); 497 + }, 498 + child: const Text('Continue this thread →'), 499 + ), 500 + ), 501 + ); 502 + } 503 + } 504 + 505 + class _ThreadLineButton extends StatelessWidget { 506 + const _ThreadLineButton({required this.color, required this.postUri, required this.isCollapsed, required this.onTap}); 507 + 508 + final Color color; 509 + final String postUri; 510 + final bool isCollapsed; 511 + final VoidCallback onTap; 512 + 513 + @override 514 + Widget build(BuildContext context) { 515 + return Material( 516 + color: Colors.transparent, 517 + child: InkWell( 518 + key: ValueKey('threadline-$postUri'), 519 + onTap: onTap, 520 + splashColor: color.withValues(alpha: 0.16), 521 + highlightColor: color.withValues(alpha: 0.08), 522 + child: _ThreadLine(color: color, isCollapsed: isCollapsed), 523 + ), 524 + ); 525 + } 526 + } 527 + 528 + class _ThreadLine extends StatelessWidget { 529 + const _ThreadLine({required this.color, this.isCollapsed = false}); 530 + 531 + final Color color; 532 + final bool isCollapsed; 533 + 534 + @override 535 + Widget build(BuildContext context) { 536 + return Center( 537 + child: AnimatedContainer( 538 + duration: _threadCollapseDuration, 539 + width: 2, 540 + margin: EdgeInsets.symmetric(vertical: isCollapsed ? 12 : 0), 541 + color: color, 542 + ), 543 + ); 544 + } 545 + } 546 + 547 + List<ThreadViewPost> _threadRepliesOf(ThreadViewPost thread) { 548 + return (thread.replies ?? <UThreadViewPostReplies>[]) 549 + .where((reply) => reply.isThreadViewPost) 550 + .map((reply) => reply.threadViewPost!) 551 + .toList(); 552 + } 553 + 554 + ThreadViewPost _getThreadRoot(ThreadViewPost thread) { 555 + var current = thread; 556 + while (current.parent != null && current.parent!.isThreadViewPost) { 557 + current = current.parent!.threadViewPost!; 558 + } 559 + return current; 560 + } 561 + 562 + int _countDescendantReplies(ThreadViewPost thread) { 563 + var count = 0; 564 + for (final reply in _threadRepliesOf(thread)) { 565 + count += 1 + _countDescendantReplies(reply); 566 + } 567 + return count; 568 + } 569 + 570 + String _hiddenReplyLabel(int count) => count == 1 ? '1 reply hidden' : '$count replies hidden'; 571 + 572 + List<Color> _threadLineColors(BuildContext context) { 573 + final colorScheme = Theme.of(context).colorScheme; 574 + final surface = colorScheme.surface; 575 + 576 + Color blend(Color color, double amount) => Color.lerp(color, surface, amount)!; 577 + 578 + return [ 579 + blend(colorScheme.outlineVariant, 0.08), 580 + blend(colorScheme.outline, 0.18), 581 + blend(colorScheme.primary, 0.78), 582 + blend(colorScheme.secondary, 0.74), 583 + blend(colorScheme.tertiary, 0.72), 584 + blend(colorScheme.primaryContainer, 0.62), 585 + ]; 586 + } 587 + 588 + FeedPostRecord? _parsePostRecord(Map<String, dynamic> record) { 589 + try { 590 + return FeedPostRecord.fromJson(record); 591 + } catch (_) { 592 + return null; 593 + } 594 + } 595 + 596 + String _initials(String value) { 597 + final parts = value.trim().split(RegExp(r'\s+')); 598 + if (parts.isEmpty || parts.first.isEmpty) { 599 + return '?'; 600 + } 601 + if (parts.length == 1) { 602 + return parts.first.substring(0, 1).toUpperCase(); 603 + } 604 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 605 + } 606 + 138 607 class _FocusedPostWithActions extends StatelessWidget { 139 608 const _FocusedPostWithActions({required this.thread, required this.accountDid}); 140 609 ··· 181 650 @override 182 651 Widget build(BuildContext context) { 183 652 final post = thread.post; 184 - final record = _tryParseRecord(post.record); 653 + final record = _parsePostRecord(post.record); 185 654 final timestamp = record?.createdAt ?? post.indexedAt; 186 655 187 656 return PostCard( ··· 427 896 return (root.post.uri.toString(), root.post.cid); 428 897 } 429 898 return (thread.post.uri.toString(), thread.post.cid); 430 - } 431 - 432 - FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 433 - try { 434 - return FeedPostRecord.fromJson(record); 435 - } catch (_) { 436 - return null; 437 - } 438 899 } 439 900 440 901 String _formatTimestamp(DateTime time) {
-1
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 65 65 crossAxisCount: columns, 66 66 crossAxisSpacing: _gridSpacing, 67 67 mainAxisSpacing: _gridSpacing, 68 - // Grid cards have a square media region plus fixed author/body/footer chrome. 69 68 mainAxisExtent: tileWidth + _gridCardChromeHeight, 70 69 ), 71 70 ),
+14
lib/features/settings/bloc/settings_cubit.dart
··· 13 13 bool? initialUseSystemTheme, 14 14 UiDensity? initialUiDensity, 15 15 FeedArchitecture? initialFeedArchitecture, 16 + int? initialThreadAutoCollapseDepth, 16 17 }) : super( 17 18 SettingsState( 18 19 themePalette: initialPalette ?? AppThemePalette.oxocarbon, ··· 20 21 useSystemTheme: initialUseSystemTheme ?? false, 21 22 uiDensity: initialUiDensity ?? UiDensity.standard, 22 23 feedArchitecture: initialFeedArchitecture ?? FeedArchitecture.grid, 24 + threadAutoCollapseDepth: initialThreadAutoCollapseDepth, 23 25 ), 24 26 ); 25 27 ··· 30 32 static const String _keyUseSystemTheme = 'use_system_theme'; 31 33 static const String _keyUiDensity = 'ui_density'; 32 34 static const String _keyFeedArchitecture = 'feed_architecture'; 35 + static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; 33 36 34 37 Future<void> loadSettings() async { 35 38 final paletteStr = await database.getSetting(_keyThemePalette); ··· 37 40 final useSystemStr = await database.getSetting(_keyUseSystemTheme); 38 41 final uiDensityStr = await database.getSetting(_keyUiDensity); 39 42 final feedArchStr = await database.getSetting(_keyFeedArchitecture); 43 + final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); 40 44 41 45 emit( 42 46 state.copyWith( ··· 45 49 useSystemTheme: useSystemStr == 'true', 46 50 uiDensity: UiDensity.fromString(uiDensityStr), 47 51 feedArchitecture: FeedArchitecture.fromString(feedArchStr), 52 + threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), 48 53 ), 49 54 ); 50 55 } ··· 78 83 Future<void> setFeedArchitecture(FeedArchitecture architecture) async { 79 84 await database.setSetting(_keyFeedArchitecture, architecture.name); 80 85 emit(state.copyWith(feedArchitecture: architecture)); 86 + } 87 + 88 + Future<void> setThreadAutoCollapseDepth(int? depth) async { 89 + if (depth == null) { 90 + await database.deleteSetting(_keyThreadAutoCollapseDepth); 91 + } else { 92 + await database.setSetting(_keyThreadAutoCollapseDepth, depth.toString()); 93 + } 94 + emit(state.copyWith(threadAutoCollapseDepth: depth)); 81 95 } 82 96 }
+16 -1
lib/features/settings/bloc/settings_state.dart
··· 5 5 import 'package:lazurite/core/theme/feed_architecture.dart'; 6 6 import 'package:lazurite/core/theme/ui_density.dart'; 7 7 8 + const Object _threadAutoCollapseDepthUnset = Object(); 9 + 8 10 class SettingsState extends Equatable { 9 11 const SettingsState({ 10 12 required this.themePalette, ··· 12 14 required this.useSystemTheme, 13 15 this.uiDensity = UiDensity.standard, 14 16 this.feedArchitecture = FeedArchitecture.grid, 17 + this.threadAutoCollapseDepth, 15 18 }); 16 19 17 20 final AppThemePalette themePalette; ··· 19 22 final bool useSystemTheme; 20 23 final UiDensity uiDensity; 21 24 final FeedArchitecture feedArchitecture; 25 + final int? threadAutoCollapseDepth; 22 26 23 27 ThemeData get themeData { 24 28 final base = AppTheme.getTheme(themePalette, themeVariant); ··· 31 35 bool? useSystemTheme, 32 36 UiDensity? uiDensity, 33 37 FeedArchitecture? feedArchitecture, 38 + Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, 34 39 }) { 35 40 return SettingsState( 36 41 themePalette: themePalette ?? this.themePalette, ··· 38 43 useSystemTheme: useSystemTheme ?? this.useSystemTheme, 39 44 uiDensity: uiDensity ?? this.uiDensity, 40 45 feedArchitecture: feedArchitecture ?? this.feedArchitecture, 46 + threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) 47 + ? this.threadAutoCollapseDepth 48 + : threadAutoCollapseDepth as int?, 41 49 ); 42 50 } 43 51 44 52 @override 45 - List<Object?> get props => [themePalette, themeVariant, useSystemTheme, uiDensity, feedArchitecture]; 53 + List<Object?> get props => [ 54 + themePalette, 55 + themeVariant, 56 + useSystemTheme, 57 + uiDensity, 58 + feedArchitecture, 59 + threadAutoCollapseDepth, 60 + ]; 46 61 }
+99
lib/features/settings/presentation/settings_screen.dart
··· 3 3 import 'package:go_router/go_router.dart'; 4 4 import 'package:lazurite/core/router/app_shell.dart'; 5 5 import 'package:lazurite/core/theme/app_theme.dart'; 6 + import 'package:lazurite/core/theme/feed_architecture.dart'; 7 + import 'package:lazurite/core/theme/ui_density.dart'; 6 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 9 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 8 10 import 'package:lazurite/features/settings/bloc/settings_state.dart'; ··· 47 49 const SizedBox(height: 24), 48 50 _buildSectionHeader(context, 'Appearance'), 49 51 _buildThemeSelector(context), 52 + const SizedBox(height: 24), 53 + _buildSectionHeader(context, 'Layout'), 54 + _buildLayoutSettings(context), 50 55 const SizedBox(height: 24), 51 56 _buildSectionHeader(context, 'Account'), 52 57 _SettingsTile( ··· 197 202 }, 198 203 ); 199 204 } 205 + 206 + Widget _buildLayoutSettings(BuildContext context) { 207 + final settingsCubit = context.read<SettingsCubit>(); 208 + 209 + return BlocBuilder<SettingsCubit, SettingsState>( 210 + builder: (context, state) { 211 + return Container( 212 + decoration: BoxDecoration( 213 + border: Border( 214 + top: BorderSide(color: Theme.of(context).dividerColor), 215 + bottom: BorderSide(color: Theme.of(context).dividerColor), 216 + ), 217 + color: Theme.of(context).cardColor, 218 + ), 219 + child: Column( 220 + children: [ 221 + _SettingsDropdownTile<UiDensity>( 222 + title: 'UI Density', 223 + value: state.uiDensity, 224 + options: UiDensity.values, 225 + labelBuilder: (density) => switch (density) { 226 + UiDensity.compact => 'Compact', 227 + UiDensity.standard => 'Standard', 228 + UiDensity.relaxed => 'Relaxed', 229 + }, 230 + onChanged: (value) { 231 + if (value != null) { 232 + settingsCubit.setUiDensity(value); 233 + } 234 + }, 235 + ), 236 + const Divider(height: 1), 237 + _SettingsDropdownTile<FeedArchitecture>( 238 + title: 'Feed Architecture', 239 + value: state.feedArchitecture, 240 + options: FeedArchitecture.values, 241 + labelBuilder: (architecture) => switch (architecture) { 242 + FeedArchitecture.grid => 'Grid', 243 + FeedArchitecture.linear => 'Linear', 244 + }, 245 + onChanged: (value) { 246 + if (value != null) { 247 + settingsCubit.setFeedArchitecture(value); 248 + } 249 + }, 250 + ), 251 + const Divider(height: 1), 252 + _SettingsDropdownTile<int?>( 253 + title: 'Thread Auto-Collapse', 254 + subtitle: 'Collapse reply branches deeper than the selected level', 255 + value: state.threadAutoCollapseDepth, 256 + options: const <int?>[null, 1, 2, 3, 4, 5, 6], 257 + labelBuilder: (depth) => depth == null ? 'Off' : 'Depth $depth', 258 + onChanged: settingsCubit.setThreadAutoCollapseDepth, 259 + ), 260 + ], 261 + ), 262 + ); 263 + }, 264 + ); 265 + } 200 266 } 201 267 202 268 enum _AppearanceMode { ··· 241 307 Icon(Icons.check, color: Theme.of(context).colorScheme.primary, size: 20), 242 308 ], 243 309 ], 310 + ), 311 + ); 312 + } 313 + } 314 + 315 + class _SettingsDropdownTile<T> extends StatelessWidget { 316 + const _SettingsDropdownTile({ 317 + required this.title, 318 + required this.value, 319 + required this.options, 320 + required this.labelBuilder, 321 + required this.onChanged, 322 + this.subtitle, 323 + }); 324 + 325 + final String title; 326 + final String? subtitle; 327 + final T value; 328 + final List<T> options; 329 + final String Function(T value) labelBuilder; 330 + final ValueChanged<T?> onChanged; 331 + 332 + @override 333 + Widget build(BuildContext context) { 334 + return ListTile( 335 + title: Text(title), 336 + subtitle: subtitle != null ? Text(subtitle!) : null, 337 + trailing: DropdownButtonHideUnderline( 338 + child: DropdownButton<T>( 339 + value: value, 340 + onChanged: onChanged, 341 + items: [for (final option in options) DropdownMenuItem<T>(value: option, child: Text(labelBuilder(option)))], 342 + ), 244 343 ), 245 344 ); 246 345 }
+7
test/core/database/app_database_test.dart
··· 185 185 186 186 expect(value, isNull); 187 187 }); 188 + 189 + test('should persist thread auto-collapse depth setting', () async { 190 + await database.setSetting('thread_auto_collapse_depth', '3'); 191 + final value = await database.getSetting('thread_auto_collapse_depth'); 192 + 193 + expect(value, equals('3')); 194 + }); 188 195 }); 189 196 }); 190 197 }
+351 -292
test/features/feed/presentation/post_thread_screen_test.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 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'; 8 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 10 9 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 11 10 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 12 - import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 11 + import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; 13 12 import 'package:mocktail/mocktail.dart'; 14 13 15 - class MockPostThreadCubit extends MockCubit<PostThreadState> implements PostThreadCubit {} 16 - 17 - class MockPostThreadRepository extends Mock implements PostThreadRepository {} 18 - 19 14 class MockPostActionRepository extends Mock implements PostActionRepository {} 20 15 21 16 class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} 22 17 23 18 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, 19 + required String did, 20 + required String handle, 21 + required String rkey, 22 + required String text, 23 + DateTime? createdAt, 31 24 }) { 25 + final time = createdAt ?? DateTime.utc(2026, 3, 15, 12); 32 26 return PostView( 33 27 uri: AtUri('at://$did/app.bsky.feed.post/$rkey'), 34 28 cid: 'cid-$rkey', 35 29 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, 30 + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': time.toIso8601String()}, 31 + indexedAt: time, 41 32 ); 42 33 } 43 34 44 - void main() { 45 - late MockPostThreadCubit mockCubit; 46 - late MockSavedPostsCubit mockSavedPostsCubit; 47 - late MockPostActionRepository mockPostActionRepository; 35 + ThreadViewPost _makeThread({ 36 + required String did, 37 + required String handle, 38 + required String rkey, 39 + required String text, 40 + List<ThreadViewPost> replies = const [], 41 + ThreadViewPost? parent, 42 + }) { 43 + return ThreadViewPost( 44 + post: _makePost(did: did, handle: handle, rkey: rkey, text: text), 45 + parent: parent == null ? null : UThreadViewPostParent.threadViewPost(data: parent), 46 + replies: replies.map((reply) => UThreadViewPostReplies.threadViewPost(data: reply)).toList(), 47 + ); 48 + } 48 49 49 - setUpAll(() { 50 - registerFallbackValue(AtUri.parse('at://did:plc:test/app.bsky.feed.post/fallback')); 50 + class _ReplyTreeHarness extends StatefulWidget { 51 + const _ReplyTreeHarness({ 52 + required this.thread, 53 + required this.savedPostsCubit, 54 + required this.postActionRepository, 55 + this.initialCollapsedUris = const <String>{}, 56 + this.onContinueThread, 51 57 }); 52 58 53 - setUp(() { 54 - mockCubit = MockPostThreadCubit(); 55 - mockSavedPostsCubit = MockSavedPostsCubit(); 56 - mockPostActionRepository = MockPostActionRepository(); 57 - }); 59 + final ThreadViewPost thread; 60 + final SavedPostsCubit savedPostsCubit; 61 + final PostActionRepository postActionRepository; 62 + final Set<String> initialCollapsedUris; 63 + final ValueChanged<ThreadViewPost>? onContinueThread; 58 64 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: {})); 65 + @override 66 + State<_ReplyTreeHarness> createState() => _ReplyTreeHarnessState(); 67 + } 68 + 69 + class _ReplyTreeHarnessState extends State<_ReplyTreeHarness> { 70 + late Set<String> collapsedUris; 71 + 72 + @override 73 + void initState() { 74 + super.initState(); 75 + collapsedUris = {...widget.initialCollapsedUris}; 76 + } 64 77 78 + @override 79 + Widget build(BuildContext context) { 65 80 return MaterialApp( 66 81 home: MultiRepositoryProvider( 67 82 providers: [ 68 - RepositoryProvider<PostActionRepository>.value(value: mockPostActionRepository), 69 - RepositoryProvider<String>.value(value: 'did:plc:currentuser'), 83 + RepositoryProvider<PostActionRepository>.value(value: widget.postActionRepository), 84 + RepositoryProvider<PostActionCache>(create: (_) => PostActionCache()), 70 85 ], 71 - child: MultiBlocProvider( 72 - providers: [ 73 - BlocProvider<PostThreadCubit>.value(value: mockCubit), 74 - BlocProvider<SavedPostsCubit>.value(value: mockSavedPostsCubit), 75 - ], 86 + child: BlocProvider<SavedPostsCubit>.value( 87 + value: widget.savedPostsCubit, 76 88 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 - }, 89 + body: SingleChildScrollView( 90 + child: ThreadReplyNode( 91 + thread: widget.thread, 92 + depth: 1, 93 + accountDid: 'did:plc:current', 94 + opDid: 'did:plc:op', 95 + collapsedUris: collapsedUris, 96 + onToggleCollapse: (postUri) { 97 + setState(() { 98 + if (collapsedUris.contains(postUri)) { 99 + collapsedUris.remove(postUri); 100 + } else { 101 + collapsedUris.add(postUri); 102 + } 103 + }); 104 + }, 105 + onContinueThread: widget.onContinueThread, 106 + ), 94 107 ), 95 108 ), 96 109 ), 97 110 ), 98 111 ); 99 112 } 113 + } 100 114 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))); 115 + void main() { 116 + late MockPostActionRepository mockPostActionRepository; 117 + late MockSavedPostsCubit mockSavedPostsCubit; 104 118 105 - expect(find.byType(CircularProgressIndicator), findsOneWidget); 106 - }); 119 + setUp(() { 120 + mockPostActionRepository = MockPostActionRepository(); 121 + mockSavedPostsCubit = MockSavedPostsCubit(); 107 122 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 - ); 123 + const savedState = SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {}); 124 + when(() => mockSavedPostsCubit.state).thenReturn(savedState); 125 + whenListen(mockSavedPostsCubit, const Stream<SavedPostsState>.empty(), initialState: savedState); 126 + }); 114 127 115 - expect(find.text('Failed to load thread'), findsOneWidget); 116 - expect(find.byType(FilledButton), findsOneWidget); 117 - }); 128 + testWidgets('renders nested threaded replies recursively', (tester) async { 129 + final grandchild = _makeThread( 130 + did: 'did:plc:grandchild', 131 + handle: 'grandchild.bsky.social', 132 + rkey: 'grandchild', 133 + text: 'Grandchild reply', 134 + ); 135 + final child = _makeThread( 136 + did: 'did:plc:child', 137 + handle: 'child.bsky.social', 138 + rkey: 'child', 139 + text: 'Child reply', 140 + replies: [grandchild], 141 + ); 142 + final parent = _makeThread( 143 + did: 'did:plc:parent', 144 + handle: 'parent.bsky.social', 145 + rkey: 'parent', 146 + text: 'Parent reply', 147 + replies: [child], 148 + ); 118 149 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 - ); 150 + await tester.pumpWidget( 151 + _ReplyTreeHarness( 152 + thread: parent, 153 + savedPostsCubit: mockSavedPostsCubit, 154 + postActionRepository: mockPostActionRepository, 155 + ), 156 + ); 157 + await tester.pumpAndSettle(); 126 158 127 - expect(find.text('Thread loaded'), findsOneWidget); 128 - }); 159 + expect(find.text('Parent reply', findRichText: true), findsOneWidget); 160 + expect(find.text('Child reply', findRichText: true), findsOneWidget); 161 + expect(find.text('Grandchild reply', findRichText: true), findsOneWidget); 162 + expect(find.byKey(ValueKey('threadline-${parent.post.uri}')), findsOneWidget); 163 + expect(find.byKey(ValueKey('threadline-${child.post.uri}')), findsOneWidget); 129 164 }); 130 165 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: {})); 166 + testWidgets('tapping the threadline collapses and expands a subtree', (tester) async { 167 + final grandchild = _makeThread( 168 + did: 'did:plc:grandchild', 169 + handle: 'grandchild.bsky.social', 170 + rkey: 'grandchild', 171 + text: 'Grandchild reply', 172 + ); 173 + final child = _makeThread( 174 + did: 'did:plc:child', 175 + handle: 'child.bsky.social', 176 + rkey: 'child', 177 + text: 'Child reply', 178 + replies: [grandchild], 179 + ); 180 + final parent = _makeThread( 181 + did: 'did:plc:parent', 182 + handle: 'parent.bsky.social', 183 + rkey: 'parent', 184 + text: 'Parent reply', 185 + replies: [child], 186 + ); 137 187 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'); 188 + await tester.pumpWidget( 189 + _ReplyTreeHarness( 190 + thread: parent, 191 + savedPostsCubit: mockSavedPostsCubit, 192 + postActionRepository: mockPostActionRepository, 193 + ), 194 + ); 195 + await tester.pumpAndSettle(); 144 196 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 - } 197 + await tester.tap(find.byKey(ValueKey('threadline-${parent.post.uri}'))); 198 + await tester.pump(); 199 + await tester.pump(const Duration(milliseconds: 250)); 190 200 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 - ); 201 + expect(find.text('Parent reply', findRichText: true), findsNothing); 202 + expect(find.text('Child reply', findRichText: true), findsNothing); 203 + expect(find.text('Grandchild reply', findRichText: true), findsNothing); 204 + expect(find.text('2 REPLIES HIDDEN'), findsOneWidget); 195 205 196 - await tester.pumpWidget( 197 - buildFullScreen( 198 - state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), 199 - ), 200 - ); 206 + await tester.tap(find.byKey(ValueKey('threadline-${parent.post.uri}'))); 207 + await tester.pump(); 208 + await tester.pump(const Duration(milliseconds: 250)); 201 209 202 - expect(find.text('alice.bsky.social'), findsOneWidget); 203 - expect(find.text('My focused post'), findsOneWidget); 204 - }); 210 + expect(find.text('Parent reply', findRichText: true), findsOneWidget); 211 + expect(find.text('Child reply', findRichText: true), findsOneWidget); 212 + expect(find.text('Grandchild reply', findRichText: true), findsOneWidget); 213 + }); 205 214 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)); 215 + testWidgets('long-pressing a reply body collapses the subtree', (tester) async { 216 + final child = _makeThread(did: 'did:plc:child', handle: 'child.bsky.social', rkey: 'child', text: 'Child reply'); 217 + final parent = _makeThread( 218 + did: 'did:plc:parent', 219 + handle: 'parent.bsky.social', 220 + rkey: 'parent', 221 + text: 'Parent reply', 222 + replies: [child], 223 + ); 208 224 209 - await tester.pumpWidget( 210 - buildFullScreen( 211 - state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), 212 - ), 213 - ); 225 + await tester.pumpWidget( 226 + _ReplyTreeHarness( 227 + thread: parent, 228 + savedPostsCubit: mockSavedPostsCubit, 229 + postActionRepository: mockPostActionRepository, 230 + ), 231 + ); 232 + await tester.pumpAndSettle(); 214 233 215 - expect(find.text('24 replies'), findsOneWidget); 216 - expect(find.text('12 reposts'), findsOneWidget); 217 - expect(find.text('156 likes'), findsOneWidget); 218 - }); 234 + await tester.longPress(find.text('Parent reply', findRichText: true)); 235 + await tester.pump(); 236 + await tester.pump(const Duration(milliseconds: 250)); 219 237 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 - }); 238 + expect(find.text('Parent reply', findRichText: true), findsNothing); 239 + expect(find.text('Child reply', findRichText: true), findsNothing); 240 + expect(find.text('1 REPLY HIDDEN'), findsOneWidget); 233 241 }); 234 242 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: {})); 243 + testWidgets('shows a continue link when replies exceed depth 6', (tester) async { 244 + final depth7 = _makeThread(did: 'did:plc:depth7', handle: 'depth7.bsky.social', rkey: 'depth7', text: 'Depth 7'); 245 + final depth6 = _makeThread( 246 + did: 'did:plc:depth6', 247 + handle: 'depth6.bsky.social', 248 + rkey: 'depth6', 249 + text: 'Depth 6', 250 + replies: [depth7], 251 + ); 252 + final depth5 = _makeThread( 253 + did: 'did:plc:depth5', 254 + handle: 'depth5.bsky.social', 255 + rkey: 'depth5', 256 + text: 'Depth 5', 257 + replies: [depth6], 258 + ); 259 + final depth4 = _makeThread( 260 + did: 'did:plc:depth4', 261 + handle: 'depth4.bsky.social', 262 + rkey: 'depth4', 263 + text: 'Depth 4', 264 + replies: [depth5], 265 + ); 266 + final depth3 = _makeThread( 267 + did: 'did:plc:depth3', 268 + handle: 'depth3.bsky.social', 269 + rkey: 'depth3', 270 + text: 'Depth 3', 271 + replies: [depth4], 272 + ); 273 + final depth2 = _makeThread( 274 + did: 'did:plc:depth2', 275 + handle: 'depth2.bsky.social', 276 + rkey: 'depth2', 277 + text: 'Depth 2', 278 + replies: [depth3], 279 + ); 280 + final depth1 = _makeThread( 281 + did: 'did:plc:depth1', 282 + handle: 'depth1.bsky.social', 283 + rkey: 'depth1', 284 + text: 'Depth 1', 285 + replies: [depth2], 286 + ); 241 287 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 - ); 288 + ThreadViewPost? continuedThread; 259 289 260 - expect(find.text('Thread'), findsOneWidget); 261 - }); 262 - }); 290 + await tester.pumpWidget( 291 + _ReplyTreeHarness( 292 + thread: depth1, 293 + savedPostsCubit: mockSavedPostsCubit, 294 + postActionRepository: mockPostActionRepository, 295 + onContinueThread: (thread) { 296 + continuedThread = thread; 297 + }, 298 + ), 299 + ); 300 + await tester.pumpAndSettle(); 263 301 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); 302 + expect(find.text('Depth 6', findRichText: true), findsOneWidget); 303 + expect(find.text('Depth 7', findRichText: true), findsNothing); 304 + expect(find.text('Continue this thread →'), findsOneWidget); 268 305 269 - expect(parents, isEmpty); 270 - }); 306 + await tester.scrollUntilVisible(find.text('Continue this thread →'), 200); 307 + await tester.tap(find.text('Continue this thread →')); 308 + await tester.pumpAndSettle(); 271 309 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 - ); 310 + expect(continuedThread?.post.uri.toString(), depth7.post.uri.toString()); 311 + }); 280 312 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'); 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 - ); 313 + test('computeInitialCollapsedThreadUris skips OP replies and leaves shallow branches expanded', () { 314 + final leaf = _makeThread(did: 'did:plc:leaf', handle: 'leaf.bsky.social', rkey: 'leaf', text: 'Leaf'); 315 + final deepBranch = _makeThread( 316 + did: 'did:plc:other', 317 + handle: 'other.bsky.social', 318 + rkey: 'deep-branch', 319 + text: 'Deep branch', 320 + replies: [leaf], 321 + ); 322 + final opBranch = _makeThread( 323 + did: 'did:plc:op', 324 + handle: 'op.bsky.social', 325 + rkey: 'op-branch', 326 + text: 'OP branch', 327 + replies: [leaf], 328 + ); 329 + final depth2 = _makeThread( 330 + did: 'did:plc:user2', 331 + handle: 'user2.bsky.social', 332 + rkey: 'depth2', 333 + text: 'Depth 2', 334 + replies: [deepBranch, opBranch], 335 + ); 336 + final depth1 = _makeThread( 337 + did: 'did:plc:user1', 338 + handle: 'user1.bsky.social', 339 + rkey: 'depth1', 340 + text: 'Depth 1', 341 + replies: [depth2], 342 + ); 343 + final root = _makeThread( 344 + did: 'did:plc:op', 345 + handle: 'op.bsky.social', 346 + rkey: 'root', 347 + text: 'Root', 348 + replies: [depth1], 349 + ); 315 350 316 - final parents = _extractParentChain(thread); 351 + final collapsedUris = computeInitialCollapsedThreadUris(root, autoCollapseDepth: 2); 317 352 318 - expect(parents, isEmpty); 319 - }); 353 + expect(collapsedUris, contains(deepBranch.post.uri.toString())); 354 + expect(collapsedUris, isNot(contains(opBranch.post.uri.toString()))); 355 + expect(collapsedUris, isNot(contains(leaf.post.uri.toString()))); 356 + expect(collapsedUris, isNot(contains(depth2.post.uri.toString()))); 320 357 }); 321 358 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()); 359 + testWidgets('initial collapsed URIs hide deep non-OP branches on first render', (tester) async { 360 + final hiddenLeaf = _makeThread( 361 + did: 'did:plc:hidden-leaf', 362 + handle: 'hidden-leaf.bsky.social', 363 + rkey: 'hidden-leaf', 364 + text: 'Hidden leaf', 365 + ); 366 + final visibleLeaf = _makeThread( 367 + did: 'did:plc:visible-leaf', 368 + handle: 'visible-leaf.bsky.social', 369 + rkey: 'visible-leaf', 370 + text: 'Visible leaf', 371 + ); 372 + final hiddenBranch = _makeThread( 373 + did: 'did:plc:other', 374 + handle: 'other.bsky.social', 375 + rkey: 'hidden-branch', 376 + text: 'Hidden branch', 377 + replies: [hiddenLeaf], 378 + ); 379 + final opBranch = _makeThread( 380 + did: 'did:plc:op', 381 + handle: 'op.bsky.social', 382 + rkey: 'op-branch', 383 + text: 'OP branch', 384 + replies: [visibleLeaf], 385 + ); 386 + final depth2 = _makeThread( 387 + did: 'did:plc:user2', 388 + handle: 'user2.bsky.social', 389 + rkey: 'depth2', 390 + text: 'Depth 2', 391 + replies: [hiddenBranch, opBranch], 392 + ); 393 + final depth1 = _makeThread( 394 + did: 'did:plc:user1', 395 + handle: 'user1.bsky.social', 396 + rkey: 'depth1', 397 + text: 'Depth 1', 398 + replies: [depth2], 399 + ); 400 + final root = _makeThread( 401 + did: 'did:plc:op', 402 + handle: 'op.bsky.social', 403 + rkey: 'root', 404 + text: 'Root', 405 + replies: [depth1], 406 + ); 342 407 343 - final replies = _extractThreadReplies(thread); 408 + await tester.pumpWidget( 409 + _ReplyTreeHarness( 410 + thread: depth1, 411 + savedPostsCubit: mockSavedPostsCubit, 412 + postActionRepository: mockPostActionRepository, 413 + initialCollapsedUris: computeInitialCollapsedThreadUris(root, autoCollapseDepth: 2), 414 + ), 415 + ); 416 + await tester.pumpAndSettle(); 344 417 345 - expect(replies, isEmpty); 346 - }); 418 + expect(find.text('Hidden branch', findRichText: true), findsNothing); 419 + expect(find.text('Hidden leaf', findRichText: true), findsNothing); 420 + expect(find.text('1 REPLY HIDDEN'), findsOneWidget); 421 + expect(find.text('OP branch', findRichText: true), findsOneWidget); 422 + expect(find.text('Visible leaf', findRichText: true), findsOneWidget); 347 423 }); 348 424 } 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 - }
+37 -4
test/features/settings/bloc/settings_cubit_test.dart
··· 27 27 expect(cubit.state.useSystemTheme, false); 28 28 expect(cubit.state.uiDensity, UiDensity.standard); 29 29 expect(cubit.state.feedArchitecture, FeedArchitecture.grid); 30 + expect(cubit.state.threadAutoCollapseDepth, isNull); 30 31 }); 31 32 32 33 test('accepts initial values via constructor', () { ··· 37 38 initialUseSystemTheme: true, 38 39 initialUiDensity: UiDensity.compact, 39 40 initialFeedArchitecture: FeedArchitecture.linear, 41 + initialThreadAutoCollapseDepth: 3, 40 42 ); 41 43 expect(cubit.state.themePalette, AppThemePalette.catppuccin); 42 44 expect(cubit.state.themeVariant, AppThemeVariant.light); 43 45 expect(cubit.state.useSystemTheme, true); 44 46 expect(cubit.state.uiDensity, UiDensity.compact); 45 47 expect(cubit.state.feedArchitecture, FeedArchitecture.linear); 48 + expect(cubit.state.threadAutoCollapseDepth, 3); 46 49 }); 47 50 48 51 blocTest<SettingsCubit, SettingsState>( ··· 54 57 await database.setSetting('use_system_theme', 'true'); 55 58 await database.setSetting('ui_density', 'compact'); 56 59 await database.setSetting('feed_architecture', 'linear'); 60 + await database.setSetting('thread_auto_collapse_depth', '4'); 57 61 }, 58 62 act: (cubit) => cubit.loadSettings(), 59 63 expect: () => [ ··· 62 66 .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.light) 63 67 .having((s) => s.useSystemTheme, 'useSystemTheme', true) 64 68 .having((s) => s.uiDensity, 'uiDensity', UiDensity.compact) 65 - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), 69 + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) 70 + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), 66 71 ], 67 72 ); 68 73 ··· 76 81 .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.dark) 77 82 .having((s) => s.useSystemTheme, 'useSystemTheme', false) 78 83 .having((s) => s.uiDensity, 'uiDensity', UiDensity.standard) 79 - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid), 84 + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid) 85 + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), 80 86 ], 81 87 ); 82 88 ··· 175 181 ); 176 182 177 183 blocTest<SettingsCubit, SettingsState>( 178 - 'loadSettings round-trips ui_density and feed_architecture', 184 + 'setThreadAutoCollapseDepth updates state and persists to database', 185 + build: () => SettingsCubit(database: database), 186 + act: (cubit) => cubit.setThreadAutoCollapseDepth(5), 187 + expect: () => [isA<SettingsState>().having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 5)], 188 + verify: (cubit) async { 189 + final value = await database.getSetting('thread_auto_collapse_depth'); 190 + expect(value, '5'); 191 + }, 192 + ); 193 + 194 + blocTest<SettingsCubit, SettingsState>( 195 + 'setThreadAutoCollapseDepth null clears the persisted setting', 196 + build: () => SettingsCubit(database: database, initialThreadAutoCollapseDepth: 4), 197 + setUp: () async { 198 + await database.setSetting('thread_auto_collapse_depth', '4'); 199 + }, 200 + act: (cubit) => cubit.setThreadAutoCollapseDepth(null), 201 + expect: () => [isA<SettingsState>().having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull)], 202 + verify: (cubit) async { 203 + final value = await database.getSetting('thread_auto_collapse_depth'); 204 + expect(value, isNull); 205 + }, 206 + ); 207 + 208 + blocTest<SettingsCubit, SettingsState>( 209 + 'loadSettings round-trips ui_density, feed_architecture, and thread auto-collapse depth', 179 210 build: () => SettingsCubit(database: database), 180 211 setUp: () async { 181 212 await database.setSetting('ui_density', 'relaxed'); 182 213 await database.setSetting('feed_architecture', 'linear'); 214 + await database.setSetting('thread_auto_collapse_depth', '6'); 183 215 }, 184 216 act: (cubit) => cubit.loadSettings(), 185 217 expect: () => [ 186 218 isA<SettingsState>() 187 219 .having((s) => s.uiDensity, 'uiDensity', UiDensity.relaxed) 188 - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), 220 + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) 221 + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 6), 189 222 ], 190 223 ); 191 224 });
+45
test/features/settings/bloc/settings_state_test.dart
··· 100 100 expect(state1, isNot(equals(state2))); 101 101 }); 102 102 103 + test('inequality when threadAutoCollapseDepth differs', () { 104 + const state1 = SettingsState( 105 + themePalette: AppThemePalette.oxocarbon, 106 + themeVariant: AppThemeVariant.dark, 107 + useSystemTheme: false, 108 + threadAutoCollapseDepth: 2, 109 + ); 110 + const state2 = SettingsState( 111 + themePalette: AppThemePalette.oxocarbon, 112 + themeVariant: AppThemeVariant.dark, 113 + useSystemTheme: false, 114 + threadAutoCollapseDepth: 4, 115 + ); 116 + 117 + expect(state1, isNot(equals(state2))); 118 + }); 119 + 103 120 test('copyWith returns new instance with updated values', () { 104 121 const original = SettingsState( 105 122 themePalette: AppThemePalette.oxocarbon, ··· 113 130 useSystemTheme: true, 114 131 uiDensity: UiDensity.compact, 115 132 feedArchitecture: FeedArchitecture.linear, 133 + threadAutoCollapseDepth: 3, 116 134 ); 117 135 118 136 expect(updated.themePalette, AppThemePalette.nord); ··· 120 138 expect(updated.useSystemTheme, true); 121 139 expect(updated.uiDensity, UiDensity.compact); 122 140 expect(updated.feedArchitecture, FeedArchitecture.linear); 141 + expect(updated.threadAutoCollapseDepth, 3); 123 142 expect(original.themePalette, AppThemePalette.oxocarbon); 124 143 }); 125 144 ··· 130 149 useSystemTheme: true, 131 150 uiDensity: UiDensity.relaxed, 132 151 feedArchitecture: FeedArchitecture.linear, 152 + threadAutoCollapseDepth: 4, 133 153 ); 134 154 135 155 final updated = original.copyWith(); ··· 139 159 expect(updated.useSystemTheme, true); 140 160 expect(updated.uiDensity, UiDensity.relaxed); 141 161 expect(updated.feedArchitecture, FeedArchitecture.linear); 162 + expect(updated.threadAutoCollapseDepth, 4); 163 + }); 164 + 165 + test('copyWith can clear threadAutoCollapseDepth', () { 166 + const original = SettingsState( 167 + themePalette: AppThemePalette.catppuccin, 168 + themeVariant: AppThemeVariant.light, 169 + useSystemTheme: true, 170 + threadAutoCollapseDepth: 5, 171 + ); 172 + 173 + final updated = original.copyWith(threadAutoCollapseDepth: null); 174 + 175 + expect(updated.threadAutoCollapseDepth, isNull); 142 176 }); 143 177 144 178 test('props includes all fields', () { ··· 148 182 useSystemTheme: true, 149 183 uiDensity: UiDensity.compact, 150 184 feedArchitecture: FeedArchitecture.linear, 185 + threadAutoCollapseDepth: 6, 151 186 ); 152 187 153 188 expect(state.props, contains(AppThemePalette.rosePine)); ··· 155 190 expect(state.props, contains(true)); 156 191 expect(state.props, contains(UiDensity.compact)); 157 192 expect(state.props, contains(FeedArchitecture.linear)); 193 + expect(state.props, contains(6)); 158 194 }); 159 195 160 196 test('defaults uiDensity to standard', () { ··· 173 209 useSystemTheme: false, 174 210 ); 175 211 expect(state.feedArchitecture, FeedArchitecture.grid); 212 + }); 213 + 214 + test('defaults threadAutoCollapseDepth to null', () { 215 + const state = SettingsState( 216 + themePalette: AppThemePalette.oxocarbon, 217 + themeVariant: AppThemeVariant.dark, 218 + useSystemTheme: false, 219 + ); 220 + expect(state.threadAutoCollapseDepth, isNull); 176 221 }); 177 222 }); 178 223 }