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: follow audit UI

+1144 -29
+29 -29
docs/tasks/phase-8.md
··· 35 35 36 36 #### Follow Audit Screen 37 37 38 - - [ ] `FollowAuditScreen` — new file `lib/features/profile/presentation/follow_audit_screen.dart` 39 - - [ ] Header — "Clean Follows" title, subtitle with total follow count 40 - - [ ] Action bar — "Scan" button (initial) → "Unfollow Selected (N)" button (ready), disabled during loading states 41 - - [ ] Linear progress bar — during fetch/classify, shows "Fetching follows: X/Y" or "Classifying: X/Y" 42 - - [ ] Failed profiles warning — amber text below progress bar when `failedProfiles > 0` 43 - - [ ] Results list — checkbox, handle (tappable → navigate to profile via GoRouter), truncated DID, status badge chip. Selected rows get destructive-red background tint 44 - - [ ] Empty state — "No problematic follows found" when audit completes with 0 results 45 - - [ ] Complete state — "Unfollowed N account(s)" after successful batch delete 46 - - [ ] Error state — error message with "Retry" button 38 + - [x] `FollowAuditScreen` — new file `lib/features/profile/presentation/follow_audit_screen.dart` 39 + - [x] Header — "Clean Follows" title, subtitle with total follow count 40 + - [x] Action bar — "Scan" button (initial) → "Unfollow Selected (N)" button (ready), disabled during loading states 41 + - [x] Linear progress bar — during fetch/classify, shows "Fetching follows: X/Y" or "Classifying: X/Y" 42 + - [x] Failed profiles warning — amber text below progress bar when `failedProfiles > 0` 43 + - [x] Results list — checkbox, handle (tappable → navigate to profile via GoRouter), truncated DID, status badge chip. Selected rows get destructive-red background tint 44 + - [x] Empty state — "No problematic follows found" when audit completes with 0 results 45 + - [x] Complete state — "Unfollowed N account(s)" after successful batch delete 46 + - [x] Error state — error message with "Retry" button 47 47 48 48 #### Filter Controls 49 49 50 - - [ ] Responsive layout — horizontal scrollable chip row on narrow screens (`< 600px`), sticky sidebar on wider screens 51 - - [ ] Per-status filter tile — visibility toggle (show/hide rows of that status in list) + "Select All" checkbox 52 - - [ ] Category count badges — show count of results per status category 53 - - [ ] Summary line — "Selected: N/M" count, always visible 50 + - [x] Responsive layout — horizontal scrollable chip row on narrow screens (`< 600px`), sticky sidebar on wider screens 51 + - [x] Per-status filter tile — visibility toggle (show/hide rows of that status in list) + "Select All" checkbox 52 + - [x] Category count badges — show count of results per status category 53 + - [x] Summary line — "Selected: N/M" count, always visible 54 54 55 55 #### Navigation & Entry Points 56 56 57 - - [ ] Settings screen — new "Account Maintenance" section with "Clean Follows" tile, navigates to `FollowAuditScreen` 58 - - [ ] Profile screen overflow menu — add "Clean Follows" option when viewing own profile, navigates to `FollowAuditScreen` 59 - - [ ] GoRouter route — `/settings/clean-follows` 57 + - [x] Settings screen — new "Account Maintenance" section with "Clean Follows" tile, navigates to `FollowAuditScreen` 58 + - [x] Profile screen overflow menu — add "Clean Follows" option when viewing own profile, navigates to `FollowAuditScreen` 59 + - [x] GoRouter route — `/settings/clean-follows` 60 60 61 61 ### Tests 62 62 ··· 91 91 92 92 #### Widget Tests (FollowAuditScreen) 93 93 94 - - [ ] initial state renders "Scan" button 95 - - [ ] fetching state shows progress bar with count text 96 - - [ ] ready state renders results list with correct status badges 97 - - [ ] selecting a record changes row background to red tint 98 - - [ ] "Unfollow Selected" button shows correct count and is disabled when nothing selected 99 - - [ ] filter toggles hide/show rows by status 100 - - [ ] "Select All" per category selects all visible records of that status 101 - - [ ] complete state shows "Unfollowed N account(s)" 102 - - [ ] error state shows message and retry button 103 - - [ ] empty results shows "No problematic follows found" 104 - - [ ] tapping handle navigates to profile screen 105 - - [ ] responsive layout: chips on narrow, sidebar on wide 94 + - [x] initial state renders "Scan" button 95 + - [x] fetching state shows progress bar with count text 96 + - [x] ready state renders results list with correct status badges 97 + - [x] selecting a record changes row background to red tint 98 + - [x] "Unfollow Selected" button shows correct count and is disabled when nothing selected 99 + - [x] filter toggles hide/show rows by status 100 + - [x] "Select All" per category selects all visible records of that status 101 + - [x] complete state shows "Unfollowed N account(s)" 102 + - [x] error state shows message and retry button 103 + - [x] empty results shows "No problematic follows found" 104 + - [x] tapping handle navigates to profile screen 105 + - [x] responsive layout: chips on narrow, sidebar on wide 106 106 107 107 #### Integration Tests 108 108 109 - - [ ] End-to-end: scan follows → results displayed → select records → confirm unfollow → success state 109 + - [x] End-to-end: scan follows → results displayed → select records → confirm unfollow → success state
+13
lib/core/router/app_router.dart
··· 27 27 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 28 28 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 29 29 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 30 + import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; 30 31 import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; 31 32 import 'package:lazurite/features/search/presentation/search_screen.dart'; 32 33 import 'package:lazurite/features/messages/bloc/message_bloc.dart'; ··· 46 47 import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 47 48 import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 48 49 import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; 50 + import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 49 51 import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 52 + import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 50 53 import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 51 54 import 'package:lazurite/core/network/constellation_client.dart'; 52 55 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; ··· 260 263 ), 261 264 GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 262 265 GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 266 + GoRoute( 267 + path: 'clean-follows', 268 + builder: (context, state) => BlocProvider( 269 + create: (_) => FollowAuditCubit( 270 + repository: FollowAuditRepository(bluesky: context.read<Bluesky>()), 271 + ownDid: context.read<String>(), 272 + ), 273 + child: const FollowAuditScreen(), 274 + ), 275 + ), 263 276 GoRoute( 264 277 path: 'devtools', 265 278 builder: (context, state) => DevToolsScreen(initialQuery: state.uri.queryParameters['query']),
+587
lib/features/profile/presentation/follow_audit_screen.dart
··· 1 + import 'dart:math' as math; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter/services.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 8 + import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 9 + 10 + class FollowAuditScreen extends StatelessWidget { 11 + const FollowAuditScreen({super.key}); 12 + 13 + @override 14 + Widget build(BuildContext context) { 15 + return Scaffold( 16 + appBar: AppBar(title: const Text('Clean Follows')), 17 + body: BlocBuilder<FollowAuditCubit, FollowAuditState>( 18 + builder: (context, state) { 19 + final visibleEntries = _visibleEntries(state); 20 + final countsByStatus = _countsByStatus(state.results); 21 + final selectedCount = state.selectedResults.length; 22 + final isBusy = _isBusy(state.status); 23 + 24 + return Column( 25 + children: [ 26 + _HeaderCard(totalFollows: state.totalFollows), 27 + Padding( 28 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), 29 + child: Row( 30 + children: [ 31 + Expanded( 32 + child: _AuditActionButton(status: state.status, selectedCount: selectedCount, isBusy: isBusy), 33 + ), 34 + ], 35 + ), 36 + ), 37 + _ProgressSection(status: state.status, progress: state.progress, totalFollows: state.totalFollows), 38 + if (state.failedProfiles > 0) 39 + Padding( 40 + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), 41 + child: Align( 42 + alignment: Alignment.centerLeft, 43 + child: Text( 44 + '${state.failedProfiles} profile(s) could not be loaded.', 45 + key: const Key('follow_audit_failed_warning'), 46 + style: TextStyle(color: Theme.of(context).colorScheme.tertiary), 47 + ), 48 + ), 49 + ), 50 + if (state.status == FollowAuditStatus.error) 51 + _ErrorBanner(message: state.errorMessage ?? 'Failed to complete follow audit.'), 52 + if (state.status == FollowAuditStatus.complete && state.unfollowedCount > 0) 53 + Padding( 54 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), 55 + child: Align( 56 + alignment: Alignment.centerLeft, 57 + child: Text( 58 + 'Unfollowed ${state.unfollowedCount} account(s)', 59 + key: const Key('follow_audit_complete_message'), 60 + style: Theme.of(context).textTheme.titleMedium, 61 + ), 62 + ), 63 + ), 64 + const SizedBox(height: 12), 65 + Expanded( 66 + child: LayoutBuilder( 67 + builder: (context, constraints) { 68 + final isNarrow = constraints.maxWidth < 600; 69 + final filters = _FilterControls(state: state, countsByStatus: countsByStatus, isNarrow: isNarrow); 70 + 71 + if (isNarrow) { 72 + return Column( 73 + children: [ 74 + filters, 75 + const SizedBox(height: 8), 76 + Expanded( 77 + child: _ResultsPanel(state: state, visibleEntries: visibleEntries), 78 + ), 79 + ], 80 + ); 81 + } 82 + 83 + return Row( 84 + children: [ 85 + SizedBox(width: 280, child: filters), 86 + VerticalDivider(width: 1, color: Theme.of(context).colorScheme.outlineVariant), 87 + Expanded( 88 + child: _ResultsPanel(state: state, visibleEntries: visibleEntries), 89 + ), 90 + ], 91 + ); 92 + }, 93 + ), 94 + ), 95 + _SummaryFooter(selectedCount: selectedCount, total: state.results.length), 96 + ], 97 + ); 98 + }, 99 + ), 100 + ); 101 + } 102 + 103 + bool _isBusy(FollowAuditStatus status) { 104 + return status == FollowAuditStatus.fetching || 105 + status == FollowAuditStatus.classifying || 106 + status == FollowAuditStatus.unfollowing; 107 + } 108 + 109 + List<({int index, ClassifiedFollow item})> _visibleEntries(FollowAuditState state) { 110 + final entries = <({int index, ClassifiedFollow item})>[]; 111 + for (var i = 0; i < state.results.length; i++) { 112 + final item = state.results[i]; 113 + if (state.visibleStatuses.contains(item.status)) { 114 + entries.add((index: i, item: item)); 115 + } 116 + } 117 + return entries; 118 + } 119 + 120 + Map<FollowStatus, int> _countsByStatus(List<ClassifiedFollow> results) { 121 + final map = <FollowStatus, int>{for (final status in FollowStatus.values) status: 0}; 122 + for (final item in results) { 123 + map[item.status] = (map[item.status] ?? 0) + 1; 124 + } 125 + return map; 126 + } 127 + } 128 + 129 + class _HeaderCard extends StatelessWidget { 130 + const _HeaderCard({required this.totalFollows}); 131 + 132 + final int totalFollows; 133 + 134 + @override 135 + Widget build(BuildContext context) { 136 + return Container( 137 + width: double.infinity, 138 + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), 139 + padding: const EdgeInsets.all(16), 140 + decoration: BoxDecoration( 141 + color: Theme.of(context).colorScheme.surfaceContainerLowest, 142 + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 143 + ), 144 + child: Column( 145 + crossAxisAlignment: CrossAxisAlignment.start, 146 + children: [ 147 + Text( 148 + 'CLEAN FOLLOWS', 149 + style: Theme.of(context).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 1.1), 150 + ), 151 + const SizedBox(height: 6), 152 + Text( 153 + totalFollows > 0 154 + ? '$totalFollows follows scanned for problematic accounts' 155 + : 'Scan your follows for deleted, suspended, blocked, and hidden accounts.', 156 + style: Theme.of(context).textTheme.bodyMedium, 157 + ), 158 + ], 159 + ), 160 + ); 161 + } 162 + } 163 + 164 + class _AuditActionButton extends StatelessWidget { 165 + const _AuditActionButton({required this.status, required this.selectedCount, required this.isBusy}); 166 + 167 + final FollowAuditStatus status; 168 + final int selectedCount; 169 + final bool isBusy; 170 + 171 + @override 172 + Widget build(BuildContext context) { 173 + if (status == FollowAuditStatus.initial || 174 + status == FollowAuditStatus.fetching || 175 + status == FollowAuditStatus.classifying) { 176 + return FilledButton.icon( 177 + key: const Key('follow_audit_scan_button'), 178 + onPressed: isBusy ? null : () => context.read<FollowAuditCubit>().audit(), 179 + icon: const Icon(Icons.manage_search_outlined), 180 + label: const Text('Scan'), 181 + ); 182 + } 183 + 184 + return FilledButton.tonalIcon( 185 + key: const Key('follow_audit_unfollow_button'), 186 + onPressed: selectedCount == 0 || isBusy ? null : () => context.read<FollowAuditCubit>().confirmUnfollow(), 187 + icon: const Icon(Icons.person_remove_outlined), 188 + label: Text('Unfollow Selected ($selectedCount)'), 189 + ); 190 + } 191 + } 192 + 193 + class _ProgressSection extends StatelessWidget { 194 + const _ProgressSection({required this.status, required this.progress, required this.totalFollows}); 195 + 196 + final FollowAuditStatus status; 197 + final int progress; 198 + final int totalFollows; 199 + 200 + @override 201 + Widget build(BuildContext context) { 202 + if (status != FollowAuditStatus.fetching && status != FollowAuditStatus.classifying) { 203 + return const SizedBox.shrink(); 204 + } 205 + 206 + final shownTotal = totalFollows > 0 ? totalFollows : math.max(progress, 1); 207 + final value = (progress / shownTotal).clamp(0.0, 1.0); 208 + final label = status == FollowAuditStatus.fetching 209 + ? 'Fetching follows: $progress/$shownTotal' 210 + : 'Classifying: $progress/$shownTotal'; 211 + 212 + return Padding( 213 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), 214 + child: Column( 215 + children: [ 216 + LinearProgressIndicator(key: const Key('follow_audit_progress'), value: value), 217 + const SizedBox(height: 8), 218 + Align( 219 + alignment: Alignment.centerLeft, 220 + child: Text(label, key: const Key('follow_audit_progress_label')), 221 + ), 222 + ], 223 + ), 224 + ); 225 + } 226 + } 227 + 228 + class _ErrorBanner extends StatelessWidget { 229 + const _ErrorBanner({required this.message}); 230 + 231 + final String message; 232 + 233 + @override 234 + Widget build(BuildContext context) { 235 + return Container( 236 + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), 237 + padding: const EdgeInsets.all(12), 238 + decoration: BoxDecoration( 239 + border: Border.all(color: Theme.of(context).colorScheme.error), 240 + color: Theme.of(context).colorScheme.errorContainer, 241 + ), 242 + child: Row( 243 + crossAxisAlignment: CrossAxisAlignment.start, 244 + children: [ 245 + Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer), 246 + const SizedBox(width: 12), 247 + Expanded( 248 + child: Column( 249 + crossAxisAlignment: CrossAxisAlignment.start, 250 + children: [ 251 + Text(message, style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer)), 252 + const SizedBox(height: 8), 253 + OutlinedButton( 254 + key: const Key('follow_audit_retry_button'), 255 + onPressed: () => context.read<FollowAuditCubit>().audit(), 256 + child: const Text('Retry'), 257 + ), 258 + ], 259 + ), 260 + ), 261 + ], 262 + ), 263 + ); 264 + } 265 + } 266 + 267 + class _FilterControls extends StatelessWidget { 268 + const _FilterControls({required this.state, required this.countsByStatus, required this.isNarrow}); 269 + 270 + final FollowAuditState state; 271 + final Map<FollowStatus, int> countsByStatus; 272 + final bool isNarrow; 273 + 274 + @override 275 + Widget build(BuildContext context) { 276 + final content = isNarrow 277 + ? SingleChildScrollView( 278 + key: const Key('follow_audit_filter_chips'), 279 + scrollDirection: Axis.horizontal, 280 + padding: const EdgeInsets.symmetric(horizontal: 16), 281 + child: Row( 282 + children: [ 283 + for (final status in FollowStatus.values) 284 + Padding( 285 + padding: const EdgeInsets.only(right: 8), 286 + child: _FilterTile( 287 + status: status, 288 + count: countsByStatus[status] ?? 0, 289 + records: state.results.where((item) => item.status == status).toList(), 290 + visibleStatuses: state.visibleStatuses, 291 + compact: true, 292 + ), 293 + ), 294 + ], 295 + ), 296 + ) 297 + : Container( 298 + key: const Key('follow_audit_filter_sidebar'), 299 + color: Theme.of(context).colorScheme.surfaceContainerLowest, 300 + child: ListView( 301 + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), 302 + children: [ 303 + Padding( 304 + padding: const EdgeInsets.fromLTRB(4, 8, 4, 8), 305 + child: Text( 306 + 'FILTERS', 307 + style: Theme.of( 308 + context, 309 + ).textTheme.labelMedium?.copyWith(letterSpacing: 1.0, fontWeight: FontWeight.w700), 310 + ), 311 + ), 312 + for (final status in FollowStatus.values) 313 + Padding( 314 + padding: const EdgeInsets.only(bottom: 8), 315 + child: _FilterTile( 316 + status: status, 317 + count: countsByStatus[status] ?? 0, 318 + records: state.results.where((item) => item.status == status).toList(), 319 + visibleStatuses: state.visibleStatuses, 320 + compact: false, 321 + ), 322 + ), 323 + ], 324 + ), 325 + ); 326 + 327 + return SizedBox(height: isNarrow ? 112 : null, child: content); 328 + } 329 + } 330 + 331 + class _FilterTile extends StatelessWidget { 332 + const _FilterTile({ 333 + required this.status, 334 + required this.count, 335 + required this.records, 336 + required this.visibleStatuses, 337 + required this.compact, 338 + }); 339 + 340 + final FollowStatus status; 341 + final int count; 342 + final List<ClassifiedFollow> records; 343 + final Set<FollowStatus> visibleStatuses; 344 + final bool compact; 345 + 346 + @override 347 + Widget build(BuildContext context) { 348 + final allSelected = records.isNotEmpty && records.every((item) => item.selected); 349 + final someSelected = records.any((item) => item.selected); 350 + final isVisible = visibleStatuses.contains(status); 351 + 352 + return Container( 353 + width: compact ? 220 : null, 354 + padding: EdgeInsets.symmetric(horizontal: compact ? 10 : 12, vertical: compact ? 8 : 10), 355 + decoration: BoxDecoration( 356 + color: Theme.of(context).colorScheme.surfaceContainerLowest, 357 + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 358 + ), 359 + child: Column( 360 + crossAxisAlignment: CrossAxisAlignment.start, 361 + children: [ 362 + Row( 363 + children: [ 364 + Expanded( 365 + child: Text( 366 + _labelForStatus(status).toUpperCase(), 367 + maxLines: 1, 368 + overflow: TextOverflow.ellipsis, 369 + style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), 370 + ), 371 + ), 372 + Container( 373 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 374 + decoration: BoxDecoration( 375 + color: Theme.of(context).colorScheme.surfaceContainerHigh, 376 + borderRadius: BorderRadius.circular(999), 377 + ), 378 + child: Text('$count', style: Theme.of(context).textTheme.labelSmall), 379 + ), 380 + ], 381 + ), 382 + const SizedBox(height: 6), 383 + Row( 384 + children: [ 385 + IconButton( 386 + key: Key('follow_audit_visibility_${status.name}'), 387 + visualDensity: VisualDensity.compact, 388 + icon: Icon(isVisible ? Icons.visibility_outlined : Icons.visibility_off_outlined), 389 + onPressed: () => context.read<FollowAuditCubit>().toggleVisibility(status), 390 + tooltip: isVisible ? 'Hide ${_labelForStatus(status)}' : 'Show ${_labelForStatus(status)}', 391 + ), 392 + Checkbox( 393 + key: Key('follow_audit_select_all_${status.name}'), 394 + value: allSelected, 395 + tristate: someSelected && !allSelected, 396 + onChanged: count == 0 397 + ? null 398 + : (value) { 399 + if (value == true) { 400 + context.read<FollowAuditCubit>().selectAllByStatus(status); 401 + return; 402 + } 403 + context.read<FollowAuditCubit>().deselectAllByStatus(status); 404 + }, 405 + ), 406 + Expanded( 407 + child: Text( 408 + 'Select All', 409 + maxLines: 1, 410 + overflow: TextOverflow.ellipsis, 411 + style: Theme.of(context).textTheme.bodySmall, 412 + ), 413 + ), 414 + ], 415 + ), 416 + ], 417 + ), 418 + ); 419 + } 420 + 421 + String _labelForStatus(FollowStatus status) { 422 + switch (status) { 423 + case FollowStatus.deleted: 424 + return 'Deleted'; 425 + case FollowStatus.deactivated: 426 + return 'Deactivated'; 427 + case FollowStatus.suspended: 428 + return 'Suspended'; 429 + case FollowStatus.blockedBy: 430 + return 'Blocked by'; 431 + case FollowStatus.blocking: 432 + return 'Blocking'; 433 + case FollowStatus.mutualBlock: 434 + return 'Mutual block'; 435 + case FollowStatus.hidden: 436 + return 'Hidden'; 437 + case FollowStatus.selfFollow: 438 + return 'Self-follow'; 439 + } 440 + } 441 + } 442 + 443 + class _ResultsPanel extends StatelessWidget { 444 + const _ResultsPanel({required this.state, required this.visibleEntries}); 445 + 446 + final FollowAuditState state; 447 + final List<({int index, ClassifiedFollow item})> visibleEntries; 448 + 449 + @override 450 + Widget build(BuildContext context) { 451 + if (state.status == FollowAuditStatus.initial) { 452 + return const Center(child: Text('Tap Scan to audit your follow list.')); 453 + } 454 + 455 + if (state.results.isEmpty && 456 + (state.status == FollowAuditStatus.ready || state.status == FollowAuditStatus.complete)) { 457 + return const Center(child: Text('No problematic follows found', key: Key('follow_audit_empty_message'))); 458 + } 459 + 460 + if (visibleEntries.isEmpty && state.results.isNotEmpty) { 461 + return const Center(child: Text('No results visible for the current filters.')); 462 + } 463 + 464 + return ListView.separated( 465 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), 466 + itemCount: visibleEntries.length, 467 + separatorBuilder: (context, index) => const SizedBox(height: 8), 468 + itemBuilder: (context, index) { 469 + final entry = visibleEntries[index]; 470 + return _ResultRow(index: entry.index, item: entry.item); 471 + }, 472 + ); 473 + } 474 + } 475 + 476 + class _ResultRow extends StatelessWidget { 477 + const _ResultRow({required this.index, required this.item}); 478 + 479 + final int index; 480 + final ClassifiedFollow item; 481 + 482 + @override 483 + Widget build(BuildContext context) { 484 + final selectedTint = Theme.of(context).colorScheme.error.withValues(alpha: 0.08); 485 + 486 + return Container( 487 + key: Key('follow_audit_row_${item.record.rkey}'), 488 + color: item.selected ? selectedTint : Theme.of(context).colorScheme.surfaceContainerLowest, 489 + child: Material( 490 + color: Colors.transparent, 491 + child: InkWell( 492 + onTap: () => context.read<FollowAuditCubit>().toggleSelection(index), 493 + child: Padding( 494 + padding: const EdgeInsets.fromLTRB(8, 8, 12, 8), 495 + child: Row( 496 + crossAxisAlignment: CrossAxisAlignment.start, 497 + children: [ 498 + Checkbox( 499 + key: Key('follow_audit_checkbox_${item.record.rkey}'), 500 + value: item.selected, 501 + onChanged: (_) => context.read<FollowAuditCubit>().toggleSelection(index), 502 + ), 503 + Expanded( 504 + child: Column( 505 + crossAxisAlignment: CrossAxisAlignment.start, 506 + children: [ 507 + TextButton( 508 + key: Key('follow_audit_handle_${item.record.rkey}'), 509 + style: TextButton.styleFrom(padding: EdgeInsets.zero, minimumSize: const Size(0, 0)), 510 + onPressed: () { 511 + context.push('/profile/view?actor=${Uri.encodeComponent(item.record.subjectDid)}'); 512 + }, 513 + child: Text( 514 + item.handle ?? item.record.subjectDid, 515 + overflow: TextOverflow.ellipsis, 516 + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), 517 + ), 518 + ), 519 + const SizedBox(height: 2), 520 + InkWell( 521 + onTap: () async { 522 + await Clipboard.setData(ClipboardData(text: item.record.subjectDid)); 523 + if (context.mounted) { 524 + ScaffoldMessenger.of(context).showSnackBar( 525 + const SnackBar( 526 + content: Text('DID copied to clipboard'), 527 + behavior: SnackBarBehavior.floating, 528 + ), 529 + ); 530 + } 531 + }, 532 + child: Text( 533 + _truncateDid(item.record.subjectDid), 534 + maxLines: 1, 535 + overflow: TextOverflow.ellipsis, 536 + style: Theme.of(context).textTheme.bodySmall?.copyWith( 537 + fontFamily: 'JetBrains Mono', 538 + color: Theme.of(context).colorScheme.onSurfaceVariant, 539 + ), 540 + ), 541 + ), 542 + ], 543 + ), 544 + ), 545 + const SizedBox(width: 8), 546 + Chip(label: Text(item.statusLabel), visualDensity: VisualDensity.compact), 547 + ], 548 + ), 549 + ), 550 + ), 551 + ), 552 + ); 553 + } 554 + 555 + String _truncateDid(String did) { 556 + if (did.length <= 24) { 557 + return did; 558 + } 559 + 560 + final prefix = did.substring(0, 14); 561 + final suffix = did.substring(did.length - 8); 562 + return '$prefix...$suffix'; 563 + } 564 + } 565 + 566 + class _SummaryFooter extends StatelessWidget { 567 + const _SummaryFooter({required this.selectedCount, required this.total}); 568 + 569 + final int selectedCount; 570 + final int total; 571 + 572 + @override 573 + Widget build(BuildContext context) { 574 + return Container( 575 + key: const Key('follow_audit_summary'), 576 + width: double.infinity, 577 + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), 578 + decoration: BoxDecoration( 579 + border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant)), 580 + ), 581 + child: Text( 582 + 'Selected: $selectedCount/$total', 583 + style: Theme.of(context).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), 584 + ), 585 + ); 586 + } 587 + }
+8
lib/features/profile/presentation/profile_screen.dart
··· 528 528 ); 529 529 }, 530 530 ), 531 + ListTile( 532 + leading: const Icon(Icons.cleaning_services_outlined), 533 + title: const Text('Clean Follows'), 534 + onTap: () { 535 + Navigator.pop(sheetContext); 536 + context.push('/settings/clean-follows'); 537 + }, 538 + ), 531 539 ], 532 540 ), 533 541 ),
+8
lib/features/settings/presentation/settings_screen.dart
··· 100 100 onTap: () => context.push('/settings/video-limits'), 101 101 ), 102 102 const SizedBox(height: 24), 103 + _buildSectionHeader(context, 'Account Maintenance'), 104 + _SettingsTile( 105 + icon: Icons.cleaning_services_outlined, 106 + title: 'Clean Follows', 107 + subtitle: 'Audit and unfollow problematic accounts in bulk', 108 + onTap: () => context.push('/settings/clean-follows'), 109 + ), 110 + const SizedBox(height: 24), 103 111 _buildSectionHeader(context, 'Advanced'), 104 112 _buildAdvancedSettings(context), 105 113 const SizedBox(height: 24),
+102
test/features/profile/presentation/follow_audit_integration_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 5 + import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 6 + import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; 7 + 8 + class _ScriptedFollowAuditRepository implements FollowAuditRepository { 9 + _ScriptedFollowAuditRepository({required this.records, required this.classified}); 10 + 11 + final List<FollowRecord> records; 12 + final List<ClassifiedFollow> classified; 13 + final List<List<ClassifiedFollow>> unfollowCalls = []; 14 + 15 + @override 16 + Future<int> batchUnfollow(List<ClassifiedFollow> selected, String ownDid) async { 17 + unfollowCalls.add(selected); 18 + return selected.length; 19 + } 20 + 21 + @override 22 + Future<({int failedCount, List<ClassifiedFollow> results})> classifyFollows( 23 + List<FollowRecord> records, 24 + String ownDid, { 25 + void Function(int classified)? onProgress, 26 + }) async { 27 + for (var i = 1; i <= records.length; i++) { 28 + onProgress?.call(i); 29 + } 30 + return (results: classified, failedCount: 0); 31 + } 32 + 33 + @override 34 + Future<List<FollowRecord>> fetchAllFollows(String did, {void Function(int fetched)? onProgress}) async { 35 + for (var i = 1; i <= records.length; i++) { 36 + onProgress?.call(i); 37 + } 38 + return records; 39 + } 40 + 41 + @override 42 + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); 43 + } 44 + 45 + void main() { 46 + testWidgets('scan follows, select one, and batch unfollow to completion', (tester) async { 47 + final records = [ 48 + const FollowRecord( 49 + uri: 'at://did:plc:me/app.bsky.graph.follow/alice', 50 + rkey: 'alice', 51 + subjectDid: 'did:plc:alice', 52 + ), 53 + const FollowRecord(uri: 'at://did:plc:me/app.bsky.graph.follow/bob', rkey: 'bob', subjectDid: 'did:plc:bob'), 54 + ]; 55 + 56 + final classified = [ 57 + ClassifiedFollow( 58 + record: records[0], 59 + handle: 'alice.bsky.social', 60 + status: FollowStatus.deleted, 61 + statusLabel: 'Deleted', 62 + ), 63 + ClassifiedFollow( 64 + record: records[1], 65 + handle: 'bob.bsky.social', 66 + status: FollowStatus.blockedBy, 67 + statusLabel: 'Blocked by', 68 + ), 69 + ]; 70 + 71 + final repository = _ScriptedFollowAuditRepository(records: records, classified: classified); 72 + final cubit = FollowAuditCubit(repository: repository, ownDid: 'did:plc:me'); 73 + addTearDown(cubit.close); 74 + 75 + await tester.pumpWidget( 76 + MaterialApp( 77 + home: BlocProvider<FollowAuditCubit>.value(value: cubit, child: const FollowAuditScreen()), 78 + ), 79 + ); 80 + 81 + expect(find.text('Scan'), findsOneWidget); 82 + 83 + await tester.tap(find.byKey(const Key('follow_audit_scan_button'))); 84 + await tester.pumpAndSettle(); 85 + 86 + expect(find.text('alice.bsky.social'), findsOneWidget); 87 + expect(find.text('bob.bsky.social'), findsOneWidget); 88 + 89 + await tester.tap(find.byKey(const Key('follow_audit_checkbox_alice'))); 90 + await tester.pumpAndSettle(); 91 + 92 + expect(find.text('Unfollow Selected (1)'), findsOneWidget); 93 + 94 + await tester.tap(find.byKey(const Key('follow_audit_unfollow_button'))); 95 + await tester.pumpAndSettle(); 96 + 97 + expect(find.text('Unfollowed 1 account(s)'), findsOneWidget); 98 + expect(repository.unfollowCalls.length, 1); 99 + expect(repository.unfollowCalls.first.length, 1); 100 + expect(repository.unfollowCalls.first.first.record.rkey, 'alice'); 101 + }); 102 + }
+326
test/features/profile/presentation/follow_audit_screen_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 7 + import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 8 + import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockFollowAuditCubit extends MockCubit<FollowAuditState> implements FollowAuditCubit {} 12 + 13 + FollowRecord _record(String did, String rkey) { 14 + return FollowRecord(uri: 'at://did:plc:owner/app.bsky.graph.follow/$rkey', rkey: rkey, subjectDid: did); 15 + } 16 + 17 + ClassifiedFollow _classified({ 18 + required String did, 19 + required String rkey, 20 + required FollowStatus status, 21 + required String statusLabel, 22 + bool selected = false, 23 + }) { 24 + return ClassifiedFollow( 25 + record: _record(did, rkey), 26 + handle: '$rkey.bsky.social', 27 + status: status, 28 + statusLabel: statusLabel, 29 + selected: selected, 30 + ); 31 + } 32 + 33 + Widget _buildSubject(MockFollowAuditCubit cubit) { 34 + return MaterialApp( 35 + home: BlocProvider<FollowAuditCubit>.value(value: cubit, child: const FollowAuditScreen()), 36 + ); 37 + } 38 + 39 + Widget _buildRoutedSubject(MockFollowAuditCubit cubit) { 40 + final router = GoRouter( 41 + routes: [ 42 + GoRoute( 43 + path: '/', 44 + builder: (context, state) => 45 + BlocProvider<FollowAuditCubit>.value(value: cubit, child: const FollowAuditScreen()), 46 + ), 47 + GoRoute( 48 + path: '/profile/view', 49 + builder: (context, state) => Scaffold(body: Text('profile:${state.uri.queryParameters['actor'] ?? ''}')), 50 + ), 51 + ], 52 + ); 53 + 54 + return MaterialApp.router(routerConfig: router); 55 + } 56 + 57 + void main() { 58 + late MockFollowAuditCubit cubit; 59 + 60 + setUp(() { 61 + cubit = MockFollowAuditCubit(); 62 + 63 + when(() => cubit.audit()).thenAnswer((_) async {}); 64 + when(() => cubit.confirmUnfollow()).thenAnswer((_) async {}); 65 + when(() => cubit.toggleSelection(any())).thenReturn(null); 66 + when(() => cubit.selectAllByStatus(FollowStatus.deleted)).thenReturn(null); 67 + when(() => cubit.selectAllByStatus(FollowStatus.blockedBy)).thenReturn(null); 68 + when(() => cubit.deselectAllByStatus(FollowStatus.deleted)).thenReturn(null); 69 + when(() => cubit.deselectAllByStatus(FollowStatus.blockedBy)).thenReturn(null); 70 + when(() => cubit.toggleVisibility(FollowStatus.deleted)).thenReturn(null); 71 + when(() => cubit.toggleVisibility(FollowStatus.blockedBy)).thenReturn(null); 72 + 73 + const initialState = FollowAuditState(); 74 + when(() => cubit.state).thenReturn(initialState); 75 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: initialState); 76 + }); 77 + 78 + testWidgets('initial state renders Scan button', (tester) async { 79 + await tester.pumpWidget(_buildSubject(cubit)); 80 + 81 + expect(find.byKey(const Key('follow_audit_scan_button')), findsOneWidget); 82 + expect(find.text('Scan'), findsOneWidget); 83 + }); 84 + 85 + testWidgets('fetching state shows progress bar with count text', (tester) async { 86 + const state = FollowAuditState(status: FollowAuditStatus.fetching, progress: 3, totalFollows: 7); 87 + when(() => cubit.state).thenReturn(state); 88 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 89 + 90 + await tester.pumpWidget(_buildSubject(cubit)); 91 + 92 + expect(find.byKey(const Key('follow_audit_progress')), findsOneWidget); 93 + expect(find.text('Fetching follows: 3/7'), findsOneWidget); 94 + }); 95 + 96 + testWidgets('ready state renders results list with status badges', (tester) async { 97 + final results = [ 98 + _classified(did: 'did:plc:alice', rkey: 'alice', status: FollowStatus.deleted, statusLabel: 'Deleted'), 99 + _classified(did: 'did:plc:bob', rkey: 'bob', status: FollowStatus.blockedBy, statusLabel: 'Blocked by'), 100 + ]; 101 + final state = FollowAuditState( 102 + status: FollowAuditStatus.ready, 103 + results: results, 104 + visibleStatuses: FollowStatus.values.toSet(), 105 + ); 106 + 107 + when(() => cubit.state).thenReturn(state); 108 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 109 + 110 + await tester.pumpWidget(_buildSubject(cubit)); 111 + 112 + expect(find.text('alice.bsky.social'), findsOneWidget); 113 + expect(find.text('bob.bsky.social'), findsOneWidget); 114 + expect(find.text('Deleted'), findsOneWidget); 115 + expect(find.text('Blocked by'), findsOneWidget); 116 + }); 117 + 118 + testWidgets('selected record row has destructive tint', (tester) async { 119 + final results = [ 120 + _classified( 121 + did: 'did:plc:alice', 122 + rkey: 'alice', 123 + status: FollowStatus.deleted, 124 + statusLabel: 'Deleted', 125 + selected: true, 126 + ), 127 + ]; 128 + final state = FollowAuditState( 129 + status: FollowAuditStatus.ready, 130 + results: results, 131 + visibleStatuses: FollowStatus.values.toSet(), 132 + ); 133 + 134 + when(() => cubit.state).thenReturn(state); 135 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 136 + 137 + await tester.pumpWidget(_buildSubject(cubit)); 138 + 139 + final row = tester.widget<Container>(find.byKey(const Key('follow_audit_row_alice'))); 140 + expect(row.color, isNotNull); 141 + }); 142 + 143 + testWidgets('Unfollow Selected button shows selected count', (tester) async { 144 + final results = [ 145 + _classified( 146 + did: 'did:plc:alice', 147 + rkey: 'alice', 148 + status: FollowStatus.deleted, 149 + statusLabel: 'Deleted', 150 + selected: true, 151 + ), 152 + _classified(did: 'did:plc:bob', rkey: 'bob', status: FollowStatus.blockedBy, statusLabel: 'Blocked by'), 153 + ]; 154 + final state = FollowAuditState( 155 + status: FollowAuditStatus.ready, 156 + results: results, 157 + visibleStatuses: FollowStatus.values.toSet(), 158 + ); 159 + 160 + when(() => cubit.state).thenReturn(state); 161 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 162 + 163 + await tester.pumpWidget(_buildSubject(cubit)); 164 + 165 + expect(find.text('Unfollow Selected (1)'), findsOneWidget); 166 + }); 167 + 168 + testWidgets('Unfollow Selected button is disabled when nothing selected', (tester) async { 169 + final results = [ 170 + _classified(did: 'did:plc:alice', rkey: 'alice', status: FollowStatus.deleted, statusLabel: 'Deleted'), 171 + ]; 172 + final state = FollowAuditState( 173 + status: FollowAuditStatus.ready, 174 + results: results, 175 + visibleStatuses: FollowStatus.values.toSet(), 176 + ); 177 + 178 + when(() => cubit.state).thenReturn(state); 179 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 180 + 181 + await tester.pumpWidget(_buildSubject(cubit)); 182 + 183 + final button = tester.widget<FilledButton>(find.byKey(const Key('follow_audit_unfollow_button'))); 184 + expect(button.onPressed, isNull); 185 + }); 186 + 187 + testWidgets('visibility filters hide rows outside visible statuses', (tester) async { 188 + final results = [ 189 + _classified(did: 'did:plc:alice', rkey: 'alice', status: FollowStatus.deleted, statusLabel: 'Deleted'), 190 + _classified(did: 'did:plc:bob', rkey: 'bob', status: FollowStatus.blockedBy, statusLabel: 'Blocked by'), 191 + ]; 192 + final state = FollowAuditState( 193 + status: FollowAuditStatus.ready, 194 + results: results, 195 + visibleStatuses: const {FollowStatus.blockedBy}, 196 + ); 197 + 198 + when(() => cubit.state).thenReturn(state); 199 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 200 + 201 + await tester.pumpWidget(_buildSubject(cubit)); 202 + 203 + expect(find.text('alice.bsky.social'), findsNothing); 204 + expect(find.text('bob.bsky.social'), findsOneWidget); 205 + 206 + await tester.tap(find.byKey(const Key('follow_audit_visibility_deleted'))); 207 + verify(() => cubit.toggleVisibility(FollowStatus.deleted)).called(1); 208 + }); 209 + 210 + testWidgets('Select All checkbox calls selectAllByStatus for the category', (tester) async { 211 + final results = [ 212 + _classified(did: 'did:plc:alice', rkey: 'alice', status: FollowStatus.deleted, statusLabel: 'Deleted'), 213 + _classified(did: 'did:plc:bob', rkey: 'bob', status: FollowStatus.deleted, statusLabel: 'Deleted'), 214 + ]; 215 + final state = FollowAuditState( 216 + status: FollowAuditStatus.ready, 217 + results: results, 218 + visibleStatuses: FollowStatus.values.toSet(), 219 + ); 220 + 221 + when(() => cubit.state).thenReturn(state); 222 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 223 + 224 + await tester.pumpWidget(_buildSubject(cubit)); 225 + 226 + await tester.tap(find.byKey(const Key('follow_audit_select_all_deleted'))); 227 + verify(() => cubit.selectAllByStatus(FollowStatus.deleted)).called(1); 228 + }); 229 + 230 + testWidgets('complete state shows unfollow count message', (tester) async { 231 + const state = FollowAuditState(status: FollowAuditStatus.complete, unfollowedCount: 2, visibleStatuses: {}); 232 + when(() => cubit.state).thenReturn(state); 233 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 234 + 235 + await tester.pumpWidget(_buildSubject(cubit)); 236 + 237 + expect(find.byKey(const Key('follow_audit_complete_message')), findsOneWidget); 238 + expect(find.text('Unfollowed 2 account(s)'), findsOneWidget); 239 + }); 240 + 241 + testWidgets('error state shows message and retry button', (tester) async { 242 + const state = FollowAuditState(status: FollowAuditStatus.error, errorMessage: 'network failure'); 243 + when(() => cubit.state).thenReturn(state); 244 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 245 + 246 + await tester.pumpWidget(_buildSubject(cubit)); 247 + 248 + expect(find.text('network failure'), findsOneWidget); 249 + expect(find.byKey(const Key('follow_audit_retry_button')), findsOneWidget); 250 + 251 + await tester.tap(find.byKey(const Key('follow_audit_retry_button'))); 252 + verify(() => cubit.audit()).called(1); 253 + }); 254 + 255 + testWidgets('ready state with no results shows empty message', (tester) async { 256 + final state = FollowAuditState( 257 + status: FollowAuditStatus.ready, 258 + results: const [], 259 + visibleStatuses: FollowStatus.values.toSet(), 260 + ); 261 + when(() => cubit.state).thenReturn(state); 262 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 263 + 264 + await tester.pumpWidget(_buildSubject(cubit)); 265 + 266 + expect(find.byKey(const Key('follow_audit_empty_message')), findsOneWidget); 267 + expect(find.text('No problematic follows found'), findsOneWidget); 268 + }); 269 + 270 + testWidgets('tapping a handle navigates to profile screen', (tester) async { 271 + final results = [ 272 + _classified(did: 'did:plc:alice', rkey: 'alice', status: FollowStatus.deleted, statusLabel: 'Deleted'), 273 + ]; 274 + final state = FollowAuditState( 275 + status: FollowAuditStatus.ready, 276 + results: results, 277 + visibleStatuses: FollowStatus.values.toSet(), 278 + ); 279 + when(() => cubit.state).thenReturn(state); 280 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 281 + 282 + await tester.pumpWidget(_buildRoutedSubject(cubit)); 283 + await tester.pumpAndSettle(); 284 + 285 + await tester.tap(find.byKey(const Key('follow_audit_handle_alice'))); 286 + await tester.pumpAndSettle(); 287 + 288 + expect(find.text('profile:did:plc:alice'), findsOneWidget); 289 + }); 290 + 291 + testWidgets('renders horizontal filter chips on narrow width', (tester) async { 292 + addTearDown(() => tester.binding.setSurfaceSize(null)); 293 + await tester.binding.setSurfaceSize(const Size(500, 900)); 294 + 295 + final state = FollowAuditState( 296 + status: FollowAuditStatus.ready, 297 + results: [_classified(did: 'did:plc:alice', rkey: 'alice', status: FollowStatus.deleted, statusLabel: 'Deleted')], 298 + visibleStatuses: FollowStatus.values.toSet(), 299 + ); 300 + when(() => cubit.state).thenReturn(state); 301 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 302 + 303 + await tester.pumpWidget(_buildSubject(cubit)); 304 + 305 + expect(find.byKey(const Key('follow_audit_filter_chips')), findsOneWidget); 306 + expect(find.byKey(const Key('follow_audit_filter_sidebar')), findsNothing); 307 + }); 308 + 309 + testWidgets('renders sidebar filters on wide width', (tester) async { 310 + addTearDown(() => tester.binding.setSurfaceSize(null)); 311 + await tester.binding.setSurfaceSize(const Size(900, 900)); 312 + 313 + final state = FollowAuditState( 314 + status: FollowAuditStatus.ready, 315 + results: [_classified(did: 'did:plc:alice', rkey: 'alice', status: FollowStatus.deleted, statusLabel: 'Deleted')], 316 + visibleStatuses: FollowStatus.values.toSet(), 317 + ); 318 + when(() => cubit.state).thenReturn(state); 319 + whenListen(cubit, const Stream<FollowAuditState>.empty(), initialState: state); 320 + 321 + await tester.pumpWidget(_buildSubject(cubit)); 322 + 323 + expect(find.byKey(const Key('follow_audit_filter_chips')), findsNothing); 324 + expect(find.byKey(const Key('follow_audit_filter_sidebar')), findsOneWidget); 325 + }); 326 + }
+42
test/features/profile/presentation/profile_screen_test.dart
··· 675 675 await tester.pumpAndSettle(); 676 676 677 677 expect(find.text('Suggested Follows'), findsNothing); 678 + expect(find.text('Clean Follows'), findsOneWidget); 679 + }); 680 + }); 681 + 682 + group('Own profile overflow menu', () { 683 + testWidgets('Clean Follows option navigates to clean follows screen', (tester) async { 684 + useLargeScreen(tester); 685 + 686 + final router = GoRouter( 687 + routes: [ 688 + GoRoute( 689 + path: '/', 690 + builder: (context, state) => MultiBlocProvider( 691 + providers: [ 692 + BlocProvider<AuthBloc>.value(value: authBloc), 693 + BlocProvider<ProfileBloc>.value(value: profileBloc), 694 + BlocProvider<FeedBloc>.value(value: feedBloc), 695 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 696 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 697 + ], 698 + child: const ProfileScreen(), 699 + ), 700 + ), 701 + GoRoute( 702 + path: '/settings/clean-follows', 703 + builder: (context, state) => const Scaffold(body: Text('clean')), 704 + ), 705 + ], 706 + ); 707 + 708 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 709 + await tester.pumpAndSettle(); 710 + 711 + await tester.tap(find.byIcon(Icons.more_vert)); 712 + await tester.pumpAndSettle(); 713 + 714 + await tester.tap(find.text('Clean Follows')); 715 + await tester.pumpAndSettle(); 716 + 717 + expect(find.text('clean'), findsOneWidget); 718 + 719 + router.dispose(); 678 720 }); 679 721 }); 680 722 }
+29
test/features/settings/presentation/settings_screen_test.dart
··· 90 90 path: '/settings/devtools', 91 91 builder: (context, state) => Scaffold(body: Text('devtools:${state.uri.queryParameters['query'] ?? ''}')), 92 92 ), 93 + GoRoute( 94 + path: '/settings/clean-follows', 95 + builder: (context, state) => const Scaffold(body: Text('clean-follows')), 96 + ), 93 97 ], 94 98 ); 95 99 ··· 264 268 265 269 expect(find.text('Video Upload Limits'), findsOneWidget); 266 270 expect(find.text('Check your daily video quota'), findsOneWidget); 271 + }); 272 + 273 + testWidgets('shows Account Maintenance section with Clean Follows tile', (tester) async { 274 + await tester.pumpWidget(buildSubject()); 275 + await tester.pumpAndSettle(); 276 + 277 + await tester.scrollUntilVisible(find.text('ACCOUNT MAINTENANCE'), 300); 278 + await tester.pumpAndSettle(); 279 + 280 + expect(find.text('ACCOUNT MAINTENANCE'), findsOneWidget); 281 + expect(find.text('Clean Follows'), findsOneWidget); 282 + expect(find.text('Audit and unfollow problematic accounts in bulk'), findsOneWidget); 283 + }); 284 + 285 + testWidgets('tapping Clean Follows tile navigates to clean follows screen', (tester) async { 286 + await tester.pumpWidget(buildRoutedSubject()); 287 + await tester.pumpAndSettle(); 288 + 289 + await tester.scrollUntilVisible(find.text('Clean Follows'), 300); 290 + await tester.pumpAndSettle(); 291 + 292 + await tester.tap(find.text('Clean Follows')); 293 + await tester.pumpAndSettle(); 294 + 295 + expect(find.text('clean-follows'), findsOneWidget); 267 296 }); 268 297 } 269 298