diff --git a/docs/tasks/ui-refactor.md b/docs/tasks/ui-refactor.md index 134f789..4797548 100644 --- a/docs/tasks/ui-refactor.md +++ b/docs/tasks/ui-refactor.md @@ -56,16 +56,16 @@ ## M6 — Collapsible Threaded Replies -- [ ] Recursive `ThreadReplyNode` widget that renders nested replies from `ThreadViewPost.replies` -- [ ] Indentation with cumulative `24px` left padding per depth level -- [ ] Color-coded vertical threadlines (cycle palette of 6 muted theme-derived colors) -- [ ] Tap-threadline-to-collapse interaction with `24dp` touch target -- [ ] Long-press-to-collapse as secondary affordance -- [ ] Collapsed state: header visible, body/children hidden, "N replies hidden" indicator -- [ ] `AnimatedSize` / `AnimatedCrossFade` collapse transition (`200ms`) -- [ ] Depth cap at 6 with "Continue this thread →" navigation link -- [ ] Local collapse state via `Set` of post URIs in screen `State` -- [ ] `thread_auto_collapse_depth` setting in Drift + Drift migration -- [ ] Expose auto-collapse depth in Layout Settings screen -- [ ] Never auto-collapse OP replies -- [ ] Tests for thread tree rendering, collapse/expand, depth cap, and auto-collapse behavior +- [x] Recursive `ThreadReplyNode` widget that renders nested replies from `ThreadViewPost.replies` +- [x] Indentation with cumulative `24px` left padding per depth level +- [x] Color-coded vertical threadlines (cycle palette of 6 muted theme-derived colors) +- [x] Tap-threadline-to-collapse interaction with `24dp` touch target +- [x] Long-press-to-collapse as secondary affordance +- [x] Collapsed state: header visible, body/children hidden, "N replies hidden" indicator +- [x] `AnimatedSize` / `AnimatedCrossFade` collapse transition (`200ms`) +- [x] Depth cap at 6 with "Continue this thread →" navigation link +- [x] Local collapse state via `Set` of post URIs in screen `State` +- [x] `thread_auto_collapse_depth` setting in Drift + Drift migration +- [x] Expose auto-collapse depth in Layout Settings screen +- [x] Never auto-collapse OP replies +- [x] Tests for thread tree rendering, collapse/expand, depth cap, and auto-collapse behavior diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 0e5d067..d3bc135 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -23,7 +23,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); @override - int get schemaVersion => 10; + int get schemaVersion => 11; @override MigrationStrategy get migration => MigrationStrategy( @@ -64,6 +64,12 @@ class AppDatabase extends _$AppDatabase { "INSERT OR IGNORE INTO settings (key, value) VALUES ('ui_density', 'standard'), ('feed_architecture', 'grid')", ); } + if (from < 11) { + /* + The thread auto-collapse setting is nullable and represented by + the presence or absence of a row in the existing settings table. + */ + } }, ); diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index defe891..8e6d53c 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -21,6 +21,7 @@ import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actio import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; class PostThreadScreen extends StatelessWidget { const PostThreadScreen({super.key, required this.postUri}); @@ -37,23 +38,106 @@ class PostThreadScreen extends StatelessWidget { } } -class _PostThreadContent extends StatelessWidget { +const int _maxThreadDepth = 6; +const double _threadIndentPerDepth = 24; +const double _threadLineTouchTarget = 24; +const Duration _threadCollapseDuration = Duration(milliseconds: 200); + +Set computeInitialCollapsedThreadUris(ThreadViewPost thread, {required int? autoCollapseDepth}) { + if (autoCollapseDepth == null) { + return {}; + } + + final opDid = _getThreadRoot(thread).post.author.did; + final collapsedUris = {}; + + void visit(ThreadViewPost node, int depth) { + for (final reply in _threadRepliesOf(node)) { + final childDepth = depth + 1; + final childReplies = _threadRepliesOf(reply); + if (childDepth > autoCollapseDepth && childReplies.isNotEmpty && reply.post.author.did != opDid) { + collapsedUris.add(reply.post.uri.toString()); + } + visit(reply, childDepth); + } + } + + visit(thread, 0); + return collapsedUris; +} + +class _PostThreadContent extends StatefulWidget { const _PostThreadContent({required this.postUri}); final String postUri; + @override + State<_PostThreadContent> createState() => _PostThreadContentState(); +} + +class _PostThreadContentState extends State<_PostThreadContent> { + Set _collapsedUris = {}; + String? _initializedThreadUri; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final state = context.read().state; + if (state.status == PostThreadStatus.loaded && state.thread != null) { + _syncInitialCollapsedUris(state.thread!); + } + } + + void _syncInitialCollapsedUris(ThreadViewPost thread) { + final threadUri = thread.post.uri.toString(); + if (_initializedThreadUri == threadUri) { + return; + } + + final collapsedUris = computeInitialCollapsedThreadUris( + thread, + autoCollapseDepth: context.read().state.threadAutoCollapseDepth, + ); + + setState(() { + _initializedThreadUri = threadUri; + _collapsedUris = collapsedUris; + }); + } + + void _toggleCollapsed(String postUri) { + setState(() { + if (_collapsedUris.contains(postUri)) { + _collapsedUris.remove(postUri); + } else { + _collapsedUris.add(postUri); + } + }); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Thread')), - body: BlocBuilder( - builder: (context, state) { - return switch (state.status) { - PostThreadStatus.loading => const Center(child: CircularProgressIndicator()), - PostThreadStatus.error => _buildError(context, state.error ?? 'Failed to load thread'), - PostThreadStatus.loaded => _buildThread(context, state.thread!), - }; - }, + return BlocListener( + listenWhen: (previous, current) { + if (current.status != PostThreadStatus.loaded || current.thread == null) { + return false; + } + return previous.thread?.post.uri.toString() != current.thread!.post.uri.toString(); + }, + listener: (context, state) { + _syncInitialCollapsedUris(state.thread!); + }, + child: Scaffold( + appBar: AppBar(title: const Text('Thread')), + body: BlocBuilder( + builder: (context, state) { + return switch (state.status) { + PostThreadStatus.loading => const Center(child: CircularProgressIndicator()), + PostThreadStatus.error => _buildError(context, state.error ?? 'Failed to load thread'), + PostThreadStatus.loaded => _buildThread(context, state.thread!), + }; + }, + ), ), ); } @@ -67,7 +151,10 @@ class _PostThreadContent extends StatelessWidget { const SizedBox(height: 16), Text(message), const SizedBox(height: 16), - FilledButton(onPressed: () => context.read().load(postUri), child: const Text('Retry')), + FilledButton( + onPressed: () => context.read().load(widget.postUri), + child: const Text('Retry'), + ), ], ), ); @@ -76,7 +163,8 @@ class _PostThreadContent extends StatelessWidget { Widget _buildThread(BuildContext context, ThreadViewPost thread) { final accountDid = context.read(); final parents = _getParentChain(thread); - final replies = (thread.replies ?? []).where((r) => r.isThreadViewPost).map((r) => r.threadViewPost!).toList(); + final replies = _threadRepliesOf(thread); + final opDid = (parents.isNotEmpty ? parents.first : thread).post.author.did; return ListView( children: [ @@ -102,9 +190,14 @@ class _PostThreadContent extends StatelessWidget { ), const Divider(height: 1), for (final reply in replies) - PostCardWithActions( - feedViewPost: FeedViewPost(post: reply.post), + ThreadReplyNode( + key: ValueKey('thread-reply-node-${reply.post.uri}'), + thread: reply, + depth: 1, accountDid: accountDid, + opDid: opDid, + collapsedUris: _collapsedUris, + onToggleCollapse: _toggleCollapsed, ), ], ], @@ -135,6 +228,382 @@ class _PostThreadContent extends StatelessWidget { } } +class ThreadReplyNode extends StatelessWidget { + const ThreadReplyNode({ + super.key, + required this.thread, + required this.depth, + required this.accountDid, + required this.opDid, + required this.collapsedUris, + required this.onToggleCollapse, + this.onContinueThread, + }); + + final ThreadViewPost thread; + final int depth; + final String accountDid; + final String opDid; + final Set collapsedUris; + final ValueChanged onToggleCollapse; + final ValueChanged? onContinueThread; + + @override + Widget build(BuildContext context) { + if (depth > _maxThreadDepth) { + return _ThreadOverflowLink(thread: thread, depth: depth, onContinueThread: onContinueThread); + } + + final postUri = thread.post.uri.toString(); + final replies = _threadRepliesOf(thread); + final isCollapsed = collapsedUris.contains(postUri); + final lineColor = _threadLineColors(context)[(depth - 1) % _threadLineColors(context).length]; + final indent = (depth - 1) * _threadIndentPerDepth; + final canCollapse = replies.isNotEmpty; + + return Padding( + padding: EdgeInsets.only(left: indent), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(left: _threadLineTouchTarget), + child: AnimatedSize( + duration: _threadCollapseDuration, + curve: Curves.easeInOut, + child: isCollapsed + ? _CollapsedThreadReply( + thread: thread, + hiddenReplyCount: _countDescendantReplies(thread), + onLongPress: canCollapse ? () => onToggleCollapse(postUri) : null, + ) + : _ExpandedThreadReply( + thread: thread, + depth: depth, + accountDid: accountDid, + opDid: opDid, + collapsedUris: collapsedUris, + onToggleCollapse: onToggleCollapse, + onContinueThread: onContinueThread, + ), + ), + ), + Positioned( + left: 0, + top: 0, + bottom: 0, + width: _threadLineTouchTarget, + child: canCollapse + ? _ThreadLineButton( + color: lineColor, + postUri: postUri, + isCollapsed: isCollapsed, + onTap: () => onToggleCollapse(postUri), + ) + : IgnorePointer(child: _ThreadLine(color: lineColor)), + ), + ], + ), + ); + } +} + +class _ExpandedThreadReply extends StatelessWidget { + const _ExpandedThreadReply({ + required this.thread, + required this.depth, + required this.accountDid, + required this.opDid, + required this.collapsedUris, + required this.onToggleCollapse, + this.onContinueThread, + }); + + final ThreadViewPost thread; + final int depth; + final String accountDid; + final String opDid; + final Set collapsedUris; + final ValueChanged onToggleCollapse; + final ValueChanged? onContinueThread; + + @override + Widget build(BuildContext context) { + final postUri = thread.post.uri.toString(); + final replies = _threadRepliesOf(thread); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: replies.isNotEmpty ? () => onToggleCollapse(postUri) : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PostCardWithActions( + feedViewPost: FeedViewPost(post: thread.post), + accountDid: accountDid, + ), + for (final reply in replies) + ThreadReplyNode( + key: ValueKey('thread-reply-node-${reply.post.uri}'), + thread: reply, + depth: depth + 1, + accountDid: accountDid, + opDid: opDid, + collapsedUris: collapsedUris, + onToggleCollapse: onToggleCollapse, + onContinueThread: onContinueThread, + ), + ], + ), + ); + } +} + +class _CollapsedThreadReply extends StatelessWidget { + const _CollapsedThreadReply({required this.thread, required this.hiddenReplyCount, this.onLongPress}); + + final ThreadViewPost thread; + final int hiddenReplyCount; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final post = thread.post; + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: onLongPress, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 1), + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outlineVariant), + color: colorScheme.surfaceContainerLowest, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CollapsedThreadHeader(post: post), + const SizedBox(height: 10), + Text( + _hiddenReplyLabel(hiddenReplyCount).toUpperCase(), + key: ValueKey('collapsed-indicator-${post.uri}'), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, letterSpacing: 1.1), + ), + ], + ), + ), + ), + ); + } +} + +class _CollapsedThreadHeader extends StatelessWidget { + const _CollapsedThreadHeader({required this.post}); + + final PostView post; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final timestamp = _parsePostRecord(post.record)?.createdAt ?? post.indexedAt; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border.all(color: colorScheme.outlineVariant), + ), + child: post.author.avatar != null + ? Image.network(post.author.avatar!, fit: BoxFit.cover) + : Center( + child: Text( + _initials(post.author.displayName ?? post.author.handle), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + post.author.displayName ?? post.author.handle, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Text( + DateFormat('MMM d').format(timestamp.toLocal()).toUpperCase(), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, letterSpacing: 0.8), + ), + ], + ), + const SizedBox(height: 2), + Text( + '@${post.author.handle}'.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} + +class _ThreadOverflowLink extends StatelessWidget { + const _ThreadOverflowLink({required this.thread, required this.depth, this.onContinueThread}); + + final ThreadViewPost thread; + final int depth; + final ValueChanged? onContinueThread; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: _maxThreadDepth * _threadIndentPerDepth), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton( + key: ValueKey('continue-thread-${thread.post.uri}'), + onPressed: () { + if (onContinueThread != null) { + onContinueThread!(thread); + return; + } + context.push('/post?uri=${Uri.encodeQueryComponent(thread.post.uri.toString())}'); + }, + child: const Text('Continue this thread →'), + ), + ), + ); + } +} + +class _ThreadLineButton extends StatelessWidget { + const _ThreadLineButton({required this.color, required this.postUri, required this.isCollapsed, required this.onTap}); + + final Color color; + final String postUri; + final bool isCollapsed; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey('threadline-$postUri'), + onTap: onTap, + splashColor: color.withValues(alpha: 0.16), + highlightColor: color.withValues(alpha: 0.08), + child: _ThreadLine(color: color, isCollapsed: isCollapsed), + ), + ); + } +} + +class _ThreadLine extends StatelessWidget { + const _ThreadLine({required this.color, this.isCollapsed = false}); + + final Color color; + final bool isCollapsed; + + @override + Widget build(BuildContext context) { + return Center( + child: AnimatedContainer( + duration: _threadCollapseDuration, + width: 2, + margin: EdgeInsets.symmetric(vertical: isCollapsed ? 12 : 0), + color: color, + ), + ); + } +} + +List _threadRepliesOf(ThreadViewPost thread) { + return (thread.replies ?? []) + .where((reply) => reply.isThreadViewPost) + .map((reply) => reply.threadViewPost!) + .toList(); +} + +ThreadViewPost _getThreadRoot(ThreadViewPost thread) { + var current = thread; + while (current.parent != null && current.parent!.isThreadViewPost) { + current = current.parent!.threadViewPost!; + } + return current; +} + +int _countDescendantReplies(ThreadViewPost thread) { + var count = 0; + for (final reply in _threadRepliesOf(thread)) { + count += 1 + _countDescendantReplies(reply); + } + return count; +} + +String _hiddenReplyLabel(int count) => count == 1 ? '1 reply hidden' : '$count replies hidden'; + +List _threadLineColors(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final surface = colorScheme.surface; + + Color blend(Color color, double amount) => Color.lerp(color, surface, amount)!; + + return [ + blend(colorScheme.outlineVariant, 0.08), + blend(colorScheme.outline, 0.18), + blend(colorScheme.primary, 0.78), + blend(colorScheme.secondary, 0.74), + blend(colorScheme.tertiary, 0.72), + blend(colorScheme.primaryContainer, 0.62), + ]; +} + +FeedPostRecord? _parsePostRecord(Map record) { + try { + return FeedPostRecord.fromJson(record); + } catch (_) { + return null; + } +} + +String _initials(String value) { + final parts = value.trim().split(RegExp(r'\s+')); + if (parts.isEmpty || parts.first.isEmpty) { + return '?'; + } + if (parts.length == 1) { + return parts.first.substring(0, 1).toUpperCase(); + } + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); +} + class _FocusedPostWithActions extends StatelessWidget { const _FocusedPostWithActions({required this.thread, required this.accountDid}); @@ -181,7 +650,7 @@ class _FocusedPostContent extends StatelessWidget { @override Widget build(BuildContext context) { final post = thread.post; - final record = _tryParseRecord(post.record); + final record = _parsePostRecord(post.record); final timestamp = record?.createdAt ?? post.indexedAt; return PostCard( @@ -429,14 +898,6 @@ class _FocusedPostContent extends StatelessWidget { return (thread.post.uri.toString(), thread.post.cid); } - FeedPostRecord? _tryParseRecord(Map record) { - try { - return FeedPostRecord.fromJson(record); - } catch (_) { - return null; - } - } - String _formatTimestamp(DateTime time) { return DateFormat('h:mm a · MMM d, yyyy').format(time.toLocal()); } diff --git a/lib/features/feed/presentation/widgets/feed_layout_view.dart b/lib/features/feed/presentation/widgets/feed_layout_view.dart index 6f7d754..730ae06 100644 --- a/lib/features/feed/presentation/widgets/feed_layout_view.dart +++ b/lib/features/feed/presentation/widgets/feed_layout_view.dart @@ -65,7 +65,6 @@ class FeedLayoutView extends StatelessWidget { crossAxisCount: columns, crossAxisSpacing: _gridSpacing, mainAxisSpacing: _gridSpacing, - // Grid cards have a square media region plus fixed author/body/footer chrome. mainAxisExtent: tileWidth + _gridCardChromeHeight, ), ), diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart index f626872..e40c94e 100644 --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -13,6 +13,7 @@ class SettingsCubit extends Cubit { bool? initialUseSystemTheme, UiDensity? initialUiDensity, FeedArchitecture? initialFeedArchitecture, + int? initialThreadAutoCollapseDepth, }) : super( SettingsState( themePalette: initialPalette ?? AppThemePalette.oxocarbon, @@ -20,6 +21,7 @@ class SettingsCubit extends Cubit { useSystemTheme: initialUseSystemTheme ?? false, uiDensity: initialUiDensity ?? UiDensity.standard, feedArchitecture: initialFeedArchitecture ?? FeedArchitecture.grid, + threadAutoCollapseDepth: initialThreadAutoCollapseDepth, ), ); @@ -30,6 +32,7 @@ class SettingsCubit extends Cubit { static const String _keyUseSystemTheme = 'use_system_theme'; static const String _keyUiDensity = 'ui_density'; static const String _keyFeedArchitecture = 'feed_architecture'; + static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; Future loadSettings() async { final paletteStr = await database.getSetting(_keyThemePalette); @@ -37,6 +40,7 @@ class SettingsCubit extends Cubit { final useSystemStr = await database.getSetting(_keyUseSystemTheme); final uiDensityStr = await database.getSetting(_keyUiDensity); final feedArchStr = await database.getSetting(_keyFeedArchitecture); + final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); emit( state.copyWith( @@ -45,6 +49,7 @@ class SettingsCubit extends Cubit { useSystemTheme: useSystemStr == 'true', uiDensity: UiDensity.fromString(uiDensityStr), feedArchitecture: FeedArchitecture.fromString(feedArchStr), + threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), ), ); } @@ -79,4 +84,13 @@ class SettingsCubit extends Cubit { await database.setSetting(_keyFeedArchitecture, architecture.name); emit(state.copyWith(feedArchitecture: architecture)); } + + Future setThreadAutoCollapseDepth(int? depth) async { + if (depth == null) { + await database.deleteSetting(_keyThreadAutoCollapseDepth); + } else { + await database.setSetting(_keyThreadAutoCollapseDepth, depth.toString()); + } + emit(state.copyWith(threadAutoCollapseDepth: depth)); + } } diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart index 22cb180..9200a37 100644 --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -5,6 +5,8 @@ import 'package:lazurite/core/theme/density_spacing.dart'; import 'package:lazurite/core/theme/feed_architecture.dart'; import 'package:lazurite/core/theme/ui_density.dart'; +const Object _threadAutoCollapseDepthUnset = Object(); + class SettingsState extends Equatable { const SettingsState({ required this.themePalette, @@ -12,6 +14,7 @@ class SettingsState extends Equatable { required this.useSystemTheme, this.uiDensity = UiDensity.standard, this.feedArchitecture = FeedArchitecture.grid, + this.threadAutoCollapseDepth, }); final AppThemePalette themePalette; @@ -19,6 +22,7 @@ class SettingsState extends Equatable { final bool useSystemTheme; final UiDensity uiDensity; final FeedArchitecture feedArchitecture; + final int? threadAutoCollapseDepth; ThemeData get themeData { final base = AppTheme.getTheme(themePalette, themeVariant); @@ -31,6 +35,7 @@ class SettingsState extends Equatable { bool? useSystemTheme, UiDensity? uiDensity, FeedArchitecture? feedArchitecture, + Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, }) { return SettingsState( themePalette: themePalette ?? this.themePalette, @@ -38,9 +43,19 @@ class SettingsState extends Equatable { useSystemTheme: useSystemTheme ?? this.useSystemTheme, uiDensity: uiDensity ?? this.uiDensity, feedArchitecture: feedArchitecture ?? this.feedArchitecture, + threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) + ? this.threadAutoCollapseDepth + : threadAutoCollapseDepth as int?, ); } @override - List get props => [themePalette, themeVariant, useSystemTheme, uiDensity, feedArchitecture]; + List get props => [ + themePalette, + themeVariant, + useSystemTheme, + uiDensity, + feedArchitecture, + threadAutoCollapseDepth, + ]; } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index aa1ddcd..707eec0 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/ui_density.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -48,6 +50,9 @@ class SettingsScreen extends StatelessWidget { _buildSectionHeader(context, 'Appearance'), _buildThemeSelector(context), const SizedBox(height: 24), + _buildSectionHeader(context, 'Layout'), + _buildLayoutSettings(context), + const SizedBox(height: 24), _buildSectionHeader(context, 'Account'), _SettingsTile( icon: Icons.dynamic_feed_outlined, @@ -197,6 +202,67 @@ class SettingsScreen extends StatelessWidget { }, ); } + + Widget _buildLayoutSettings(BuildContext context) { + final settingsCubit = context.read(); + + return BlocBuilder( + builder: (context, state) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + color: Theme.of(context).cardColor, + ), + child: Column( + children: [ + _SettingsDropdownTile( + title: 'UI Density', + value: state.uiDensity, + options: UiDensity.values, + labelBuilder: (density) => switch (density) { + UiDensity.compact => 'Compact', + UiDensity.standard => 'Standard', + UiDensity.relaxed => 'Relaxed', + }, + onChanged: (value) { + if (value != null) { + settingsCubit.setUiDensity(value); + } + }, + ), + const Divider(height: 1), + _SettingsDropdownTile( + title: 'Feed Architecture', + value: state.feedArchitecture, + options: FeedArchitecture.values, + labelBuilder: (architecture) => switch (architecture) { + FeedArchitecture.grid => 'Grid', + FeedArchitecture.linear => 'Linear', + }, + onChanged: (value) { + if (value != null) { + settingsCubit.setFeedArchitecture(value); + } + }, + ), + const Divider(height: 1), + _SettingsDropdownTile( + title: 'Thread Auto-Collapse', + subtitle: 'Collapse reply branches deeper than the selected level', + value: state.threadAutoCollapseDepth, + options: const [null, 1, 2, 3, 4, 5, 6], + labelBuilder: (depth) => depth == null ? 'Off' : 'Depth $depth', + onChanged: settingsCubit.setThreadAutoCollapseDepth, + ), + ], + ), + ); + }, + ); + } } enum _AppearanceMode { @@ -246,6 +312,39 @@ class _ThemePaletteRow extends StatelessWidget { } } +class _SettingsDropdownTile extends StatelessWidget { + const _SettingsDropdownTile({ + required this.title, + required this.value, + required this.options, + required this.labelBuilder, + required this.onChanged, + this.subtitle, + }); + + final String title; + final String? subtitle; + final T value; + final List options; + final String Function(T value) labelBuilder; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + onChanged: onChanged, + items: [for (final option in options) DropdownMenuItem(value: option, child: Text(labelBuilder(option)))], + ), + ), + ); + } +} + class _SettingsTile extends StatelessWidget { const _SettingsTile({ required this.title, diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index acd1c1a..0a1cd90 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -185,6 +185,13 @@ void main() { expect(value, isNull); }); + + test('should persist thread auto-collapse depth setting', () async { + await database.setSetting('thread_auto_collapse_depth', '3'); + final value = await database.getSetting('thread_auto_collapse_depth'); + + expect(value, equals('3')); + }); }); }); } diff --git a/test/features/feed/presentation/post_thread_screen_test.dart b/test/features/feed/presentation/post_thread_screen_test.dart index 0bf2ad4..d964ffd 100644 --- a/test/features/feed/presentation/post_thread_screen_test.dart +++ b/test/features/feed/presentation/post_thread_screen_test.dart @@ -5,361 +5,420 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; -import 'package:lazurite/features/feed/cubit/post_thread_cubit.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; -import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; import 'package:mocktail/mocktail.dart'; -class MockPostThreadCubit extends MockCubit implements PostThreadCubit {} - -class MockPostThreadRepository extends Mock implements PostThreadRepository {} - class MockPostActionRepository extends Mock implements PostActionRepository {} class MockSavedPostsCubit extends MockCubit implements SavedPostsCubit {} PostView _makePost({ - String did = 'did:plc:author', - String handle = 'author.bsky.social', - String rkey = 'abc', - String text = 'Hello world', - int? replyCount, - int? repostCount, - int? likeCount, + required String did, + required String handle, + required String rkey, + required String text, + DateTime? createdAt, }) { + final time = createdAt ?? DateTime.utc(2026, 3, 15, 12); return PostView( uri: AtUri('at://$did/app.bsky.feed.post/$rkey'), cid: 'cid-$rkey', author: ProfileViewBasic(did: did, handle: handle), - record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String()}, - indexedAt: DateTime.utc(2026, 3, 15), - replyCount: replyCount, - repostCount: repostCount, - likeCount: likeCount, + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': time.toIso8601String()}, + indexedAt: time, ); } -void main() { - late MockPostThreadCubit mockCubit; - late MockSavedPostsCubit mockSavedPostsCubit; - late MockPostActionRepository mockPostActionRepository; +ThreadViewPost _makeThread({ + required String did, + required String handle, + required String rkey, + required String text, + List replies = const [], + ThreadViewPost? parent, +}) { + return ThreadViewPost( + post: _makePost(did: did, handle: handle, rkey: rkey, text: text), + parent: parent == null ? null : UThreadViewPostParent.threadViewPost(data: parent), + replies: replies.map((reply) => UThreadViewPostReplies.threadViewPost(data: reply)).toList(), + ); +} - setUpAll(() { - registerFallbackValue(AtUri.parse('at://did:plc:test/app.bsky.feed.post/fallback')); +class _ReplyTreeHarness extends StatefulWidget { + const _ReplyTreeHarness({ + required this.thread, + required this.savedPostsCubit, + required this.postActionRepository, + this.initialCollapsedUris = const {}, + this.onContinueThread, }); - setUp(() { - mockCubit = MockPostThreadCubit(); - mockSavedPostsCubit = MockSavedPostsCubit(); - mockPostActionRepository = MockPostActionRepository(); - }); + final ThreadViewPost thread; + final SavedPostsCubit savedPostsCubit; + final PostActionRepository postActionRepository; + final Set initialCollapsedUris; + final ValueChanged? onContinueThread; + + @override + State<_ReplyTreeHarness> createState() => _ReplyTreeHarnessState(); +} - Widget buildSubject({PostThreadState? state}) { - when(() => mockCubit.state).thenReturn(state ?? const PostThreadState(status: PostThreadStatus.loading)); - when( - () => mockSavedPostsCubit.state, - ).thenReturn(const SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {})); +class _ReplyTreeHarnessState extends State<_ReplyTreeHarness> { + late Set collapsedUris; + @override + void initState() { + super.initState(); + collapsedUris = {...widget.initialCollapsedUris}; + } + + @override + Widget build(BuildContext context) { return MaterialApp( home: MultiRepositoryProvider( providers: [ - RepositoryProvider.value(value: mockPostActionRepository), - RepositoryProvider.value(value: 'did:plc:currentuser'), + RepositoryProvider.value(value: widget.postActionRepository), + RepositoryProvider(create: (_) => PostActionCache()), ], - child: MultiBlocProvider( - providers: [ - BlocProvider.value(value: mockCubit), - BlocProvider.value(value: mockSavedPostsCubit), - ], + child: BlocProvider.value( + value: widget.savedPostsCubit, child: Scaffold( - body: BlocBuilder( - builder: (context, cubitState) { - return switch (cubitState.status) { - PostThreadStatus.loading => const Center(child: CircularProgressIndicator()), - PostThreadStatus.error => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline), - Text(cubitState.error ?? 'Failed to load thread'), - FilledButton(onPressed: () => mockCubit.load('test'), child: const Text('Retry')), - ], - ), - ), - PostThreadStatus.loaded => const Text('Thread loaded'), - }; - }, + body: SingleChildScrollView( + child: ThreadReplyNode( + thread: widget.thread, + depth: 1, + accountDid: 'did:plc:current', + opDid: 'did:plc:op', + collapsedUris: collapsedUris, + onToggleCollapse: (postUri) { + setState(() { + if (collapsedUris.contains(postUri)) { + collapsedUris.remove(postUri); + } else { + collapsedUris.add(postUri); + } + }); + }, + onContinueThread: widget.onContinueThread, + ), ), ), ), ), ); } +} - group('PostThreadScreen states', () { - testWidgets('shows loading indicator when status is loading', (tester) async { - await tester.pumpWidget(buildSubject(state: const PostThreadState(status: PostThreadStatus.loading))); +void main() { + late MockPostActionRepository mockPostActionRepository; + late MockSavedPostsCubit mockSavedPostsCubit; - expect(find.byType(CircularProgressIndicator), findsOneWidget); - }); + setUp(() { + mockPostActionRepository = MockPostActionRepository(); + mockSavedPostsCubit = MockSavedPostsCubit(); - testWidgets('shows error message when status is error', (tester) async { - await tester.pumpWidget( - buildSubject( - state: const PostThreadState(status: PostThreadStatus.error, error: 'Failed to load thread'), - ), - ); + const savedState = SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {}); + when(() => mockSavedPostsCubit.state).thenReturn(savedState); + whenListen(mockSavedPostsCubit, const Stream.empty(), initialState: savedState); + }); - expect(find.text('Failed to load thread'), findsOneWidget); - expect(find.byType(FilledButton), findsOneWidget); - }); + testWidgets('renders nested threaded replies recursively', (tester) async { + final grandchild = _makeThread( + did: 'did:plc:grandchild', + handle: 'grandchild.bsky.social', + rkey: 'grandchild', + text: 'Grandchild reply', + ); + final child = _makeThread( + did: 'did:plc:child', + handle: 'child.bsky.social', + rkey: 'child', + text: 'Child reply', + replies: [grandchild], + ); + final parent = _makeThread( + did: 'did:plc:parent', + handle: 'parent.bsky.social', + rkey: 'parent', + text: 'Parent reply', + replies: [child], + ); - testWidgets('shows thread loaded text when status is loaded', (tester) async { - final thread = ThreadViewPost(post: _makePost()); - await tester.pumpWidget( - buildSubject( - state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), - ), - ); + await tester.pumpWidget( + _ReplyTreeHarness( + thread: parent, + savedPostsCubit: mockSavedPostsCubit, + postActionRepository: mockPostActionRepository, + ), + ); + await tester.pumpAndSettle(); - expect(find.text('Thread loaded'), findsOneWidget); - }); + expect(find.text('Parent reply', findRichText: true), findsOneWidget); + expect(find.text('Child reply', findRichText: true), findsOneWidget); + expect(find.text('Grandchild reply', findRichText: true), findsOneWidget); + expect(find.byKey(ValueKey('threadline-${parent.post.uri}')), findsOneWidget); + expect(find.byKey(ValueKey('threadline-${child.post.uri}')), findsOneWidget); }); - group('PostThreadScreen full render', () { - Widget buildFullScreen({required PostThreadState state}) { - when(() => mockCubit.state).thenReturn(state); - when( - () => mockSavedPostsCubit.state, - ).thenReturn(const SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {})); - - when( - () => mockPostActionRepository.likePost( - uri: any(named: 'uri'), - cid: any(named: 'cid'), - ), - ).thenAnswer((_) async => 'at://did:plc:test/app.bsky.feed.like/like1'); - - return MaterialApp( - home: MultiRepositoryProvider( - providers: [ - RepositoryProvider.value(value: mockPostActionRepository), - RepositoryProvider.value(value: 'did:plc:currentuser'), - ], - child: MultiBlocProvider( - providers: [ - BlocProvider.value(value: mockCubit), - BlocProvider.value(value: mockSavedPostsCubit), - ], - child: Scaffold( - appBar: AppBar(title: const Text('Thread')), - body: Builder( - builder: (context) { - if (state.status == PostThreadStatus.loaded) { - final post = state.thread!.post; - return BlocProvider( - create: (_) => PostActionCubit( - postActionRepository: mockPostActionRepository, - postUri: post.uri.toString(), - postCid: post.cid, - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(post.author.displayName ?? post.author.handle), - Text((post.record['text'] as String?) ?? ''), - if ((post.replyCount ?? 0) > 0) Text('${post.replyCount} replies'), - if ((post.repostCount ?? 0) > 0) Text('${post.repostCount} reposts'), - if ((post.likeCount ?? 0) > 0) Text('${post.likeCount} likes'), - ], - ), - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ), - ), - ), - ); - } + testWidgets('tapping the threadline collapses and expands a subtree', (tester) async { + final grandchild = _makeThread( + did: 'did:plc:grandchild', + handle: 'grandchild.bsky.social', + rkey: 'grandchild', + text: 'Grandchild reply', + ); + final child = _makeThread( + did: 'did:plc:child', + handle: 'child.bsky.social', + rkey: 'child', + text: 'Child reply', + replies: [grandchild], + ); + final parent = _makeThread( + did: 'did:plc:parent', + handle: 'parent.bsky.social', + rkey: 'parent', + text: 'Parent reply', + replies: [child], + ); - testWidgets('focused post shows author name', (tester) async { - final thread = ThreadViewPost( - post: _makePost(handle: 'alice.bsky.social', text: 'My focused post'), - ); + await tester.pumpWidget( + _ReplyTreeHarness( + thread: parent, + savedPostsCubit: mockSavedPostsCubit, + postActionRepository: mockPostActionRepository, + ), + ); + await tester.pumpAndSettle(); - await tester.pumpWidget( - buildFullScreen( - state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), - ), - ); + await tester.tap(find.byKey(ValueKey('threadline-${parent.post.uri}'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); - expect(find.text('alice.bsky.social'), findsOneWidget); - expect(find.text('My focused post'), findsOneWidget); - }); + expect(find.text('Parent reply', findRichText: true), findsNothing); + expect(find.text('Child reply', findRichText: true), findsNothing); + expect(find.text('Grandchild reply', findRichText: true), findsNothing); + expect(find.text('2 REPLIES HIDDEN'), findsOneWidget); - testWidgets('focused post shows stats when counts are non-zero', (tester) async { - final thread = ThreadViewPost(post: _makePost(replyCount: 24, repostCount: 12, likeCount: 156)); + await tester.tap(find.byKey(ValueKey('threadline-${parent.post.uri}'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); - await tester.pumpWidget( - buildFullScreen( - state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), - ), - ); + expect(find.text('Parent reply', findRichText: true), findsOneWidget); + expect(find.text('Child reply', findRichText: true), findsOneWidget); + expect(find.text('Grandchild reply', findRichText: true), findsOneWidget); + }); - expect(find.text('24 replies'), findsOneWidget); - expect(find.text('12 reposts'), findsOneWidget); - expect(find.text('156 likes'), findsOneWidget); - }); + testWidgets('long-pressing a reply body collapses the subtree', (tester) async { + final child = _makeThread(did: 'did:plc:child', handle: 'child.bsky.social', rkey: 'child', text: 'Child reply'); + final parent = _makeThread( + did: 'did:plc:parent', + handle: 'parent.bsky.social', + rkey: 'parent', + text: 'Parent reply', + replies: [child], + ); - testWidgets('focused post does not show stats when counts are zero', (tester) async { - final thread = ThreadViewPost(post: _makePost(replyCount: 0, repostCount: 0, likeCount: 0)); + await tester.pumpWidget( + _ReplyTreeHarness( + thread: parent, + savedPostsCubit: mockSavedPostsCubit, + postActionRepository: mockPostActionRepository, + ), + ); + await tester.pumpAndSettle(); - await tester.pumpWidget( - buildFullScreen( - state: PostThreadState(status: PostThreadStatus.loaded, thread: thread), - ), - ); + await tester.longPress(find.text('Parent reply', findRichText: true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); - expect(find.text('0 replies'), findsNothing); - expect(find.text('0 reposts'), findsNothing); - expect(find.text('0 likes'), findsNothing); - }); + expect(find.text('Parent reply', findRichText: true), findsNothing); + expect(find.text('Child reply', findRichText: true), findsNothing); + expect(find.text('1 REPLY HIDDEN'), findsOneWidget); }); - group('PostThreadScreen thread structure', () { - testWidgets('renders thread app bar title', (tester) async { - when(() => mockCubit.state).thenReturn(const PostThreadState(status: PostThreadStatus.loading)); - when( - () => mockSavedPostsCubit.state, - ).thenReturn(const SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {})); - - await tester.pumpWidget( - MaterialApp( - home: MultiRepositoryProvider( - providers: [ - RepositoryProvider.value(value: mockPostActionRepository), - RepositoryProvider.value(value: 'did:plc:currentuser'), - ], - child: MultiBlocProvider( - providers: [ - BlocProvider.value(value: mockCubit), - BlocProvider.value(value: mockSavedPostsCubit), - ], - child: Scaffold(appBar: AppBar(title: const Text('Thread'))), - ), - ), - ), - ); + testWidgets('shows a continue link when replies exceed depth 6', (tester) async { + final depth7 = _makeThread(did: 'did:plc:depth7', handle: 'depth7.bsky.social', rkey: 'depth7', text: 'Depth 7'); + final depth6 = _makeThread( + did: 'did:plc:depth6', + handle: 'depth6.bsky.social', + rkey: 'depth6', + text: 'Depth 6', + replies: [depth7], + ); + final depth5 = _makeThread( + did: 'did:plc:depth5', + handle: 'depth5.bsky.social', + rkey: 'depth5', + text: 'Depth 5', + replies: [depth6], + ); + final depth4 = _makeThread( + did: 'did:plc:depth4', + handle: 'depth4.bsky.social', + rkey: 'depth4', + text: 'Depth 4', + replies: [depth5], + ); + final depth3 = _makeThread( + did: 'did:plc:depth3', + handle: 'depth3.bsky.social', + rkey: 'depth3', + text: 'Depth 3', + replies: [depth4], + ); + final depth2 = _makeThread( + did: 'did:plc:depth2', + handle: 'depth2.bsky.social', + rkey: 'depth2', + text: 'Depth 2', + replies: [depth3], + ); + final depth1 = _makeThread( + did: 'did:plc:depth1', + handle: 'depth1.bsky.social', + rkey: 'depth1', + text: 'Depth 1', + replies: [depth2], + ); - expect(find.text('Thread'), findsOneWidget); - }); - }); + ThreadViewPost? continuedThread; - group('PostThreadState parent chain', () { - test('getParentChain returns empty list for root post', () { - final thread = ThreadViewPost(post: _makePost()); - final parents = _extractParentChain(thread); - - expect(parents, isEmpty); - }); - - test('getParentChain returns single parent in order', () { - final parentPost = _makePost(rkey: 'parent1', text: 'Parent post'); - final childPost = _makePost(rkey: 'child1', text: 'Child post'); - final parentThread = ThreadViewPost(post: parentPost); - final thread = ThreadViewPost( - post: childPost, - parent: UThreadViewPostParent.threadViewPost(data: parentThread), - ); - - final parents = _extractParentChain(thread); - - expect(parents.length, 1); - expect(parents.first.post.cid, 'cid-parent1'); - }); - - test('getParentChain returns chain in oldest-first order', () { - final grandparentPost = _makePost(rkey: 'gp', text: 'Grandparent'); - final parentPost = _makePost(rkey: 'p', text: 'Parent'); - final childPost = _makePost(rkey: 'c', text: 'Child'); - - final grandparentThread = ThreadViewPost(post: grandparentPost); - final parentThread = ThreadViewPost( - post: parentPost, - parent: UThreadViewPostParent.threadViewPost(data: grandparentThread), - ); - final thread = ThreadViewPost( - post: childPost, - parent: UThreadViewPostParent.threadViewPost(data: parentThread), - ); - - final parents = _extractParentChain(thread); - - expect(parents.length, 2); - expect(parents[0].post.cid, 'cid-gp'); - expect(parents[1].post.cid, 'cid-p'); - }); - - test('getParentChain stops at non-thread-view parent', () { - final childPost = _makePost(rkey: 'c', text: 'Child'); - final thread = ThreadViewPost( - post: childPost, - parent: const UThreadViewPostParent.notFoundPost(data: NotFoundPost(uri: AtUri('at://x/y/z'), notFound: true)), - ); - - final parents = _extractParentChain(thread); - - expect(parents, isEmpty); - }); - }); + await tester.pumpWidget( + _ReplyTreeHarness( + thread: depth1, + savedPostsCubit: mockSavedPostsCubit, + postActionRepository: mockPostActionRepository, + onContinueThread: (thread) { + continuedThread = thread; + }, + ), + ); + await tester.pumpAndSettle(); - group('PostThreadState replies filtering', () { - test('filters out non-thread-view replies', () { - final mainPost = _makePost(rkey: 'main'); - final replyPost = _makePost(rkey: 'reply1', text: 'A reply'); - final thread = ThreadViewPost( - post: mainPost, - replies: [ - UThreadViewPostReplies.threadViewPost(data: ThreadViewPost(post: replyPost)), - const UThreadViewPostReplies.notFoundPost(data: NotFoundPost(uri: AtUri('at://x/y/z'), notFound: true)), - ], - ); + expect(find.text('Depth 6', findRichText: true), findsOneWidget); + expect(find.text('Depth 7', findRichText: true), findsNothing); + expect(find.text('Continue this thread →'), findsOneWidget); - final replies = _extractThreadReplies(thread); + await tester.scrollUntilVisible(find.text('Continue this thread →'), 200); + await tester.tap(find.text('Continue this thread →')); + await tester.pumpAndSettle(); - expect(replies.length, 1); - expect(replies.first.post.cid, 'cid-reply1'); - }); + expect(continuedThread?.post.uri.toString(), depth7.post.uri.toString()); + }); - test('returns empty list when no replies', () { - final thread = ThreadViewPost(post: _makePost()); + test('computeInitialCollapsedThreadUris skips OP replies and leaves shallow branches expanded', () { + final leaf = _makeThread(did: 'did:plc:leaf', handle: 'leaf.bsky.social', rkey: 'leaf', text: 'Leaf'); + final deepBranch = _makeThread( + did: 'did:plc:other', + handle: 'other.bsky.social', + rkey: 'deep-branch', + text: 'Deep branch', + replies: [leaf], + ); + final opBranch = _makeThread( + did: 'did:plc:op', + handle: 'op.bsky.social', + rkey: 'op-branch', + text: 'OP branch', + replies: [leaf], + ); + final depth2 = _makeThread( + did: 'did:plc:user2', + handle: 'user2.bsky.social', + rkey: 'depth2', + text: 'Depth 2', + replies: [deepBranch, opBranch], + ); + final depth1 = _makeThread( + did: 'did:plc:user1', + handle: 'user1.bsky.social', + rkey: 'depth1', + text: 'Depth 1', + replies: [depth2], + ); + final root = _makeThread( + did: 'did:plc:op', + handle: 'op.bsky.social', + rkey: 'root', + text: 'Root', + replies: [depth1], + ); - final replies = _extractThreadReplies(thread); + final collapsedUris = computeInitialCollapsedThreadUris(root, autoCollapseDepth: 2); - expect(replies, isEmpty); - }); + expect(collapsedUris, contains(deepBranch.post.uri.toString())); + expect(collapsedUris, isNot(contains(opBranch.post.uri.toString()))); + expect(collapsedUris, isNot(contains(leaf.post.uri.toString()))); + expect(collapsedUris, isNot(contains(depth2.post.uri.toString()))); }); -} -/// Mirrors _PostThreadContent._getParentChain for unit testing. -List _extractParentChain(ThreadViewPost thread) { - final parents = []; - var current = thread.parent; - while (current != null && current.isThreadViewPost) { - final parentThread = current.threadViewPost!; - parents.add(parentThread); - current = parentThread.parent; - } - return parents.reversed.toList(); -} + testWidgets('initial collapsed URIs hide deep non-OP branches on first render', (tester) async { + final hiddenLeaf = _makeThread( + did: 'did:plc:hidden-leaf', + handle: 'hidden-leaf.bsky.social', + rkey: 'hidden-leaf', + text: 'Hidden leaf', + ); + final visibleLeaf = _makeThread( + did: 'did:plc:visible-leaf', + handle: 'visible-leaf.bsky.social', + rkey: 'visible-leaf', + text: 'Visible leaf', + ); + final hiddenBranch = _makeThread( + did: 'did:plc:other', + handle: 'other.bsky.social', + rkey: 'hidden-branch', + text: 'Hidden branch', + replies: [hiddenLeaf], + ); + final opBranch = _makeThread( + did: 'did:plc:op', + handle: 'op.bsky.social', + rkey: 'op-branch', + text: 'OP branch', + replies: [visibleLeaf], + ); + final depth2 = _makeThread( + did: 'did:plc:user2', + handle: 'user2.bsky.social', + rkey: 'depth2', + text: 'Depth 2', + replies: [hiddenBranch, opBranch], + ); + final depth1 = _makeThread( + did: 'did:plc:user1', + handle: 'user1.bsky.social', + rkey: 'depth1', + text: 'Depth 1', + replies: [depth2], + ); + final root = _makeThread( + did: 'did:plc:op', + handle: 'op.bsky.social', + rkey: 'root', + text: 'Root', + replies: [depth1], + ); -/// Mirrors the reply extraction in _PostThreadContent._buildThread. -List _extractThreadReplies(ThreadViewPost thread) { - return (thread.replies ?? []).where((r) => r.isThreadViewPost).map((r) => r.threadViewPost!).toList(); + await tester.pumpWidget( + _ReplyTreeHarness( + thread: depth1, + savedPostsCubit: mockSavedPostsCubit, + postActionRepository: mockPostActionRepository, + initialCollapsedUris: computeInitialCollapsedThreadUris(root, autoCollapseDepth: 2), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Hidden branch', findRichText: true), findsNothing); + expect(find.text('Hidden leaf', findRichText: true), findsNothing); + expect(find.text('1 REPLY HIDDEN'), findsOneWidget); + expect(find.text('OP branch', findRichText: true), findsOneWidget); + expect(find.text('Visible leaf', findRichText: true), findsOneWidget); + }); } diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart index f272a2e..60c1f2d 100644 --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -27,6 +27,7 @@ void main() { expect(cubit.state.useSystemTheme, false); expect(cubit.state.uiDensity, UiDensity.standard); expect(cubit.state.feedArchitecture, FeedArchitecture.grid); + expect(cubit.state.threadAutoCollapseDepth, isNull); }); test('accepts initial values via constructor', () { @@ -37,12 +38,14 @@ void main() { initialUseSystemTheme: true, initialUiDensity: UiDensity.compact, initialFeedArchitecture: FeedArchitecture.linear, + initialThreadAutoCollapseDepth: 3, ); expect(cubit.state.themePalette, AppThemePalette.catppuccin); expect(cubit.state.themeVariant, AppThemeVariant.light); expect(cubit.state.useSystemTheme, true); expect(cubit.state.uiDensity, UiDensity.compact); expect(cubit.state.feedArchitecture, FeedArchitecture.linear); + expect(cubit.state.threadAutoCollapseDepth, 3); }); blocTest( @@ -54,6 +57,7 @@ void main() { await database.setSetting('use_system_theme', 'true'); await database.setSetting('ui_density', 'compact'); await database.setSetting('feed_architecture', 'linear'); + await database.setSetting('thread_auto_collapse_depth', '4'); }, act: (cubit) => cubit.loadSettings(), expect: () => [ @@ -62,7 +66,8 @@ void main() { .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.light) .having((s) => s.useSystemTheme, 'useSystemTheme', true) .having((s) => s.uiDensity, 'uiDensity', UiDensity.compact) - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), ], ); @@ -76,7 +81,8 @@ void main() { .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.dark) .having((s) => s.useSystemTheme, 'useSystemTheme', false) .having((s) => s.uiDensity, 'uiDensity', UiDensity.standard) - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid), + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid) + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), ], ); @@ -175,17 +181,44 @@ void main() { ); blocTest( - 'loadSettings round-trips ui_density and feed_architecture', + 'setThreadAutoCollapseDepth updates state and persists to database', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setThreadAutoCollapseDepth(5), + expect: () => [isA().having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 5)], + verify: (cubit) async { + final value = await database.getSetting('thread_auto_collapse_depth'); + expect(value, '5'); + }, + ); + + blocTest( + 'setThreadAutoCollapseDepth null clears the persisted setting', + build: () => SettingsCubit(database: database, initialThreadAutoCollapseDepth: 4), + setUp: () async { + await database.setSetting('thread_auto_collapse_depth', '4'); + }, + act: (cubit) => cubit.setThreadAutoCollapseDepth(null), + expect: () => [isA().having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull)], + verify: (cubit) async { + final value = await database.getSetting('thread_auto_collapse_depth'); + expect(value, isNull); + }, + ); + + blocTest( + 'loadSettings round-trips ui_density, feed_architecture, and thread auto-collapse depth', build: () => SettingsCubit(database: database), setUp: () async { await database.setSetting('ui_density', 'relaxed'); await database.setSetting('feed_architecture', 'linear'); + await database.setSetting('thread_auto_collapse_depth', '6'); }, act: (cubit) => cubit.loadSettings(), expect: () => [ isA() .having((s) => s.uiDensity, 'uiDensity', UiDensity.relaxed) - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 6), ], ); }); diff --git a/test/features/settings/bloc/settings_state_test.dart b/test/features/settings/bloc/settings_state_test.dart index 5d53ab7..c17d1f6 100644 --- a/test/features/settings/bloc/settings_state_test.dart +++ b/test/features/settings/bloc/settings_state_test.dart @@ -100,6 +100,23 @@ void main() { expect(state1, isNot(equals(state2))); }); + test('inequality when threadAutoCollapseDepth differs', () { + const state1 = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + threadAutoCollapseDepth: 2, + ); + const state2 = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + threadAutoCollapseDepth: 4, + ); + + expect(state1, isNot(equals(state2))); + }); + test('copyWith returns new instance with updated values', () { const original = SettingsState( themePalette: AppThemePalette.oxocarbon, @@ -113,6 +130,7 @@ void main() { useSystemTheme: true, uiDensity: UiDensity.compact, feedArchitecture: FeedArchitecture.linear, + threadAutoCollapseDepth: 3, ); expect(updated.themePalette, AppThemePalette.nord); @@ -120,6 +138,7 @@ void main() { expect(updated.useSystemTheme, true); expect(updated.uiDensity, UiDensity.compact); expect(updated.feedArchitecture, FeedArchitecture.linear); + expect(updated.threadAutoCollapseDepth, 3); expect(original.themePalette, AppThemePalette.oxocarbon); }); @@ -130,6 +149,7 @@ void main() { useSystemTheme: true, uiDensity: UiDensity.relaxed, feedArchitecture: FeedArchitecture.linear, + threadAutoCollapseDepth: 4, ); final updated = original.copyWith(); @@ -139,6 +159,20 @@ void main() { expect(updated.useSystemTheme, true); expect(updated.uiDensity, UiDensity.relaxed); expect(updated.feedArchitecture, FeedArchitecture.linear); + expect(updated.threadAutoCollapseDepth, 4); + }); + + test('copyWith can clear threadAutoCollapseDepth', () { + const original = SettingsState( + themePalette: AppThemePalette.catppuccin, + themeVariant: AppThemeVariant.light, + useSystemTheme: true, + threadAutoCollapseDepth: 5, + ); + + final updated = original.copyWith(threadAutoCollapseDepth: null); + + expect(updated.threadAutoCollapseDepth, isNull); }); test('props includes all fields', () { @@ -148,6 +182,7 @@ void main() { useSystemTheme: true, uiDensity: UiDensity.compact, feedArchitecture: FeedArchitecture.linear, + threadAutoCollapseDepth: 6, ); expect(state.props, contains(AppThemePalette.rosePine)); @@ -155,6 +190,7 @@ void main() { expect(state.props, contains(true)); expect(state.props, contains(UiDensity.compact)); expect(state.props, contains(FeedArchitecture.linear)); + expect(state.props, contains(6)); }); test('defaults uiDensity to standard', () { @@ -174,5 +210,14 @@ void main() { ); expect(state.feedArchitecture, FeedArchitecture.grid); }); + + test('defaults threadAutoCollapseDepth to null', () { + const state = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + ); + expect(state.threadAutoCollapseDepth, isNull); + }); }); }