General: User interface and user experience overhaul#2381
Merged
Conversation
…llback like non-previewable items
The risk-level chips on list rows (Desirable / Common) are now clickable and open the same markers info dialog as the toolbar info button. Previously they were passive labels.
… when clicking risk chip on list
Filter and sort actions return to icon-only IconButtons matching the pre-Compose XML design — the AssistChips with full text labels ("Only show these file types", "Sort by") squeezed the Scan button into a vertical text column on narrow phones.
Status block restructured into per-row icon+count layout (heart/primary for keep, delete/error for delete, help-outline/onSurfaceVariant for undecided) with the progress bar moved below the rows, matching the legacy layout.
The Compose rewrite of SwiperGestureOverlay dropped the per-direction icons, used an OutlinedButton that was nearly invisible on the dark scrim, and rendered inside the Scaffold content lambda so the top app bar and bottom action bar pushed the up/down labels out of view. Three fixes: - Wrap Scaffold in a fillMaxSize Box and render SwiperGestureOverlay as a sibling so the scrim covers the top app bar, stats card, action bar, and swipe area (matching the legacy match_parent overlay). - Stack a TwoTone Delete/Favorite/Restore icon (or the ic_baseline_skip_next_24 painter) above each direction label, mirroring the old XML's ImageView + MaterialTextView pair. - Replace OutlinedButton with FilledTonalButton for the 'Got it' confirmation so the button is legible against the 80% black scrim (matches the legacy Widget.Material3.Button.TonalButton).
The warning chip on session cards (Whole-storage scan) is now clickable and opens an info dialog that explains why the scan is flagged. Previously it was a passive label with no affordance.
Replaces the large stats Card (three vertical icon/count/size columns plus linear progress bar) with the original compact pill: a 40dp CircularProgressIndicator with percent overlay followed by three inline icon+count badges for undecided/keep/delete. Uses surfaceContainerHigh and 16dp rounded corners, wrapContentWidth, centered horizontally. Drops the per-decision size byte display and the separate linear progress bar to match the pre-rewrite XML layout.
…els on swipe screen Three regressions vs. the pre-Compose swipe screen: - TopAppBar lost the session subtitle (legacy showed session label or "Session #N") - Review action lost its text label, leaving only an icon - Bottom FABs (Delete/Undo/Skip/Keep) lost their text labels Restored all three to align with the prior fragment-based layout.
The Compose rewrite of the swipe card regressed three details from the XML layout: - Both top action buttons clustered at top-right; should be Open at top-left and Preview at top-right - File info overlay dropped the modified date and the '#N of M' counter from the same row as the filename - File type chip used primaryContainer (highly visible green) and rendered above the info gradient instead of below it Buttons split into two FilledTonalIconButtons at TopStart and TopEnd. FileInfoOverlay now uses titleMedium/bodyMedium to match the legacy TitleMedium/BodyMedium styles, shows filename + position on a weighted row, and renders 'size • date' formatted via DateTimeFormatter.ofLocalizedDate(MEDIUM). FileTypeChip switched to colorSurface/onSurface and reordered before the overlay so the gradient covers it when details are visible.
Bring the swipe screen's bottom action bar back in line with the old design: - Drop the elevated bottom Surface so the buttons float above the content instead of sitting on a shadowed bar at the very bottom edge - Use 32dp horizontal / 8dp top / 24dp bottom padding so the buttons are raised off the screen edge and easier to reach - Add a small Material labelSmall caption under each button (Delete / Undo / Skip / Keep, swapped when swapDirections is on) - Reserve the Undo slot via Modifier.alpha instead of AnimatedVisibility so Skip keeps its consistent right-of-center offset whether Undo is shown or not
…layout on swipe action bar
Restore visible numbering, use surfaceContainerHigh/primaryContainer for clear background contrast, wrap current-item Visibility icon in a primaryContainer pill so it shows against the file preview, and reduce the edge fade so off-center thumbs stay readable.
Stats card now scrolls with the list instead of being pinned, and the top bar trash icon only appears once the card has scrolled off — mirrors the old CollapsingToolbarLayout + menu visibility behavior. Quick-action icons in each row switched from flat IconButton to FilledTonalIconButton with a surfaceVariant container, matching the old bg_circle_action drawable. Decision stripes at the start of each row are inset by 4dp top/bottom and clipped with rounded right corners, matching bg_decision_stripe.xml.
…rogress pill with circular indicator
…ss pager contrast
…isuals with old design
The pre-Compose XML header card placed a MaterialDivider with 12dp vertical margin between the instructional description and the starter suggestions block, visually separating the two sections. The Compose port (SwiperSessionsHeaderCard.kt) collapsed that into a single 12dp Spacer. Add a HorizontalDivider with matching 12dp spacers on either side to restore the original separation.
…view label, and action button labels on swipe screen # Conflicts: # app-tool-swiper/src/main/java/eu/darken/sdmse/swiper/ui/swipe/items/SwiperActionBar.kt
…lay with pre-rewrite design
…atch pre-Compose design
…rage warning chip on click
Both PickerItemRow and PickerSelectedRow rendered the file type icons with tint = Color.Unspecified, so the TwoTone vectors fell back to their hardcoded black fill and became invisible in dark mode. Switching to onSurfaceVariant matches the convention used by LiveSearchSheetContent for the same fileType.icon vectors.
…ker for dark mode
The KEEP and DELETE stamps were anchored to the same edge as the swipe direction, so they slid off-screen with the card during the gesture (e.g. swiping right placed the stamp on the right and was immediately hidden by the screen edge). Anchor each stamp to the opposite edge from its swipe direction so it stays visible while the card animates out. The 'existing decision' faded indicator now derives from the same direction values, removing the duplicated mapping.
StorageChip stacked Modifier.combinedClickable on top of a Material3 FilterChip. FilterChip routes through the selectable Surface(onClick) overload, which installs its own clickable node; the outer combinedClickable then competes with it on the same subtree and its onLongClick never fires. Since long-press is the only entry point to the delete-storage confirm dialog, deleting a storage's history was impossible. Replace the stacked clickables with a single combinedClickable on a plain Box that reproduces the flat-FilterChip visual (CornerSmall shape, 32dp content height, selected: secondaryContainer/onSecondaryContainer, unselected: transparent + 1dp outlineVariant border via FilterChipTokens mapping). onClick selects, onLongClick opens the dialog — one unambiguous gesture source. Preserve the chip's accessibility contract that a bare container would lose: Role.RadioButton + a `selected` semantic so TalkBack announces the current storage, onLongClickLabel for the delete action, and minimumInteractiveComponentSize() for a 48dp touch target. RangeChip is unchanged (still a real FilterChip). Add SpaceHistoryScreenTest (BaseComposeRobolectricTest) driving the internal Screen with a mock StateFlow: a tap fires onSelectStorage, and a long-press opens the confirm dialog whose Delete invokes onDeleteStorage. The long-press test fails on the pre-fix code (dialog never appears) and passes after. Both scroll the chip into view first because the chart pushes it below the Robolectric viewport.
Two latent UI bugs that were previously only pinned by tests (BUG-FIXME-9/-4):
- The filter list briefly navigated "up" during a fresh re-scan: the drain
watcher mapped over `it.data` and the `hasData` extension treats a null
Data (published while loading) as empty. It now uses
`mapNotNull { it.data }.map { it.hasData }`, mirroring the details VM, so
navUp fires only on a real drain-to-empty.
- Filter-content details emitted an "exclusions created" event with count 0
when every selected path was already excluded (save dedups to empty),
showing a "0 exclusions created" snackbar. onExcludeFilter now guards with
`if (undo.exclusionIds.isEmpty()) return@launch`, matching the list VM.
The two pinned tests are flipped to assert the corrected behavior.
The list VM gated the Pro upgrade only in onDeleteConfirmed (after the confirmation dialog), while the details VM gates it before confirming. Move the isPro() check into onRowClick / onDeleteSelected so non-Pro users are routed to the upgrade screen before the confirm dialog, aligning the two screens. onDeleteConfirmed keeps its check as a safety net (submit() also throws UpgradeRequiredException). Adds coverage for the not-pro path.
Pre-merge cleanup pass over the Fragment→Compose rewrite. No user-facing
behavior change beyond multi-selection now surviving rotation/process death.
Selection persistence:
- Add `rememberSelection<T>()` (app-common-ui/.../compose/selection): a
rememberSaveable-backed drop-in for `remember { mutableStateOf(emptySet()) }`.
Every selection id type is Bundle-saveable (Parcelable, or String/Long alias).
- Swap all tool list/detail selection bars to it, and AppControl's
pendingExportIds (fixes a silent export no-op after process death). Make the
Deduplicator list's MixedSelection @parcelize and persist it too.
- Harden two now-reachable stale-id cases: gate CustomFilterList's edit action
on the live (pruned) selection, and give each Deduplicator details pager page
its own LazyListState (was hoisted to one shared instance).
Dead code / unused API removed:
- Composables/icons/extensions with zero callers: Placeholder, SettingsDivider,
SdmIcons.FolderInfo, ColorScheme.onScrim, SdmListDefaults.CardContentPadding.
- Dead navigation leftovers: NavEvent.Finish + its handler, UpgradeRoute.from,
Stats/Deduplicator/AppControl route `from()`/`typeMap` companions and their
now-unused toRoute/SavedStateHandle/NavType imports.
- Unused injected VM deps (DashboardViewModel.squeezerSettings, ContentViewModel
context), dead DashboardViewModel.showSqueezer(), and the never-rendered
showProRequirement on the Swiper dashboard card (combine reduced 3→2 sources).
- Swiper status VM dead events/Event/retryFailed; unused exclusion-row params.
- Dead Navigation3.adaptive version constant.
Polish:
- Add missing @preview2 (populated CorpseDetails; Swiper swipe screen + items).
- CorpseFinder list/markers risk-chip colors now match the details header.
- Analyzer: stable LazyList key (path string, not hashCode) and rememberSaveable
search so the bar survives rotation.
- Scheduler editor shows a loading placeholder until the draft resolves
(consumes the previously-unread isReady flag).
- Squeezer grid preview button gets a localized contentDescription.
- Extract the debug recorder's "Sensitive Information" string; fix a TourBubble
padding style nit.
Follow-up to the cleanup pass: these were deferred earlier only to avoid colliding with the in-flight nav-guard and AppControl-preview branches, which have since merged. - NavigationController.replace(): no callers anywhere; unlike goTo() it lacks the pending-action queue, so it was never wired up. - AppActionRoute.typeMap: orphaned. The Nav3 entry<AppActionRoute> does not consult it, there is no toRoute()/from() for the route, and InstallId args round-trip via kotlinx serialization (covered by AppControlRoutesSerializationTest). Drop it and the now-unused NavType/KType/typeOf/serializableNavType imports. - AppActionSheet: unused AppExportType import. - AppControlListRow: capture appInfo.sizes once via ?.let instead of a guarded !!.
Make the list/grid card tap targets explicit and bring back direct cluster
deletion (the previously-dead deleteClusters/ConfirmDeletion flow is now wired):
- Grid: tapping the thumbnail asks to delete the cluster's duplicates, tapping
the caption opens details, and a small top-left button over the preview opens
the full-screen preview. Long-press anywhere selects.
- List: tapping the header asks to delete the cluster, the header thumbnail opens
the preview, and a sub-row asks to delete that single duplicate. No direct
details navigation (by design).
Add a VM delete-target model: DeduplicatorListRow.freeableSize sums the sizes of
the actual delete targets (every duplicate except the favorite group's keeper),
shared with deleteClusters so the grid "X freeable" line and the strengthened
confirm dialog ("Delete N duplicates in this set, freeing X") always match what
is deleted.
Polish:
- Grid primary line shows only the freeable size, single line.
- Grid detection types render as compact icons on the item-count line.
- The list delete-target marker moves before the file size so sizes stay
end-aligned across rows.
Accessibility: card regions expose Role.Button + click labels; the preview button
and thumbnails get content descriptions.
Adds ViewModel freeableSize tests (incl. the missing-keeper case) and Compose
per-region interaction tests.
The :app module declared io.github.panpf.zoomimage:zoomimage-view-coil2 directly but uses no ZoomImage API itself (only a license-attribution entry references the name). The library still arrives transitively via :app-common-coil and :app-tool-squeezer, so the attribution stays accurate and runtime behavior is unchanged.
The custom-filter live-search accumulated crawler matches with scan() and no de-duplication. LiveSearchMatch.id is the path string and the results LazyColumn keys on it, so a path emitted more than once (e.g. overlapping crawl areas) would produce duplicate keys and crash the list. Skip an event whose id is already present.
DeduplicatorListRow.kt held both card composables (~390 lines, over the ~200-line extraction guideline). Split it along the existing seam: - DeduplicatorGridRow.kt — grid card + PreviewOverlayButton + icon-only MatchTypeIcons. - DeduplicatorLinearRow.kt — linear card + DuplicateSubRow + labeled MatchTypeChipRow. Pure code move, no behaviour change: the composables are internal and in the same `items` package, so DeduplicatorListScreen and the tests are unaffected. Compiles clean (no unused imports); all 281 module tests pass.
Add a shared previewDeduplicatorListRow() sample (favorite checksum group of two copies, one delete target) and @preview2 previews for DeduplicatorGridRow (constrained to a grid-tile width) and DeduplicatorLinearRow, satisfying the all-composables preview convention. Compiles clean; no behaviour change.
- Tighten grid/linear card vertical rhythm: even out the grid caption padding and drop redundant spacers between the size and item-count lines. - Sub-row: put the file name and size on one line — name weighted and middle-ellipsized so both ends stay visible (the distinguishing suffix + extension of near-identical duplicate names), size kept end-aligned with the delete marker just left of it; the parent path moves to line two.
SwiperProgressPager, SwiperStatsCard, SwiperSwipeBackCard and SwiperFileTypeChip lacked previews. Add @preview2 functions plus a previewSwipeState() helper in SwiperPreviewData for the stats card, matching the existing swipe-card preview pattern.
DashboardListCard, ToolDashboardCard, SetupDashboardCard and SwiperDashboardCard took 'item' before 'modifier', violating the modifier-first convention the shared toolkit follows (SettingsPreferenceItem, SdmScaffold). Reorder modifier to the first parameter and update the dispatcher + screen call sites to named args. No behavior change.
The theme mode/style/color and ROM-type picker dialogs apply the selection and dismiss immediately on tap, so the trailing 'Cancel' button never reverts anything - it only closes. Relabel it to the existing 'Close' string so the affordance is accurate; the explicit button is kept as a D-pad/TalkBack-friendly dismiss control.
The hero card's "X items can be removed" line read from itemCount, which deliberately excluded the Deduplicator: its hero slice count was the cluster count (not a discrete file count), so it was filtered out to avoid mixing "sets" with the other tools' file counts. With the Deduplicator as the only tool that had results, itemCount collapsed to 0 — the card showed "51 MB can be freed / 0 items can be removed". Add Cluster.redundantCount (and Data.redundantCount): the number of files a default keep-one delete removes, distinct by id so cross-group path overlap isn't double-counted, mirroring the existing redundantSize the card already uses for its size. The Deduplicator hero slice now carries this removable-file count, and the three itemCount sites (buildHeroSummary, accumulateFreed, and the preview) sum every tool's count uniformly instead of filtering the Deduplicator out — so the headline is a true discrete-file count for every tool combination. Tests: new DuplicateRedundantCountTest (incl. distinct-by-id); a Deduplicator-only buildHeroSummary regression; and a FREED-path engine test.
Every tool's list and details ViewModel combined the scan `progress` flow
into the same `combine` that produced the (sorted) row list. During an active
scan, progress ticks many times/second, so the entire row list was
re-`sortedByDescending`/`filterSortRows`'d and re-allocated on every tick —
wasted CPU + GC pressure on the Default dispatcher and redundant State
emissions that re-ran each list's `items{}` block. (Not a main-thread stall;
the combine runs on Dispatchers.Default.)
Split each VM into a progress-free flow that produces the rows/State and an
outer `combine(rowsFlow, progress) { base.copy(progress = …) }` (for sealed
states, copy only the Ready branch). A progress tick now reuses the exact
same rows List instance, so keyed lazy rows skip and the sort/map never
re-runs on a progress-only emission. Per-VM input placement preserved
(search/filter/sort/layout/options stay on the rows side; only progress and
the fast-scroller setting move out).
Added a `getStateFlow`-backed `currentTargetFlow` to PagedDetailsViewModel
(reactive view of the saved-state "target" key) and folded it into the
Deduplicator details combine, so pager target tracking re-resolves on page
change without depending on a progress tick.
Also:
- contentType on the dashboard grid (16 card shapes) and the AppCleaner
details list (header/category/file/inaccessible) for slot reuse on fling.
- Memoized AppControl's two per-row DateTimeFormatters (keyed on
LocalConfiguration), its select-all rowIds set, and Squeezer's savings sums.
Verified: 2300+ unit tests green (incl. a new progress-only-emission
same-rows-instance guard) + Codex implementation review + on-device smoke
test on a Pixel 7a (AppCleaner, SystemCleaner, AppControl, Deduplicator
list+details pagers, dashboard) — no regressions.
Note: the per-row selection-churn refactor (multi-select only) is
intentionally deferred to a separate change.
Decouple scan progress from every tool's list/details ViewModel state so the row list isn't re-sorted/re-allocated on each progress tick during scans, plus contentType + leaf memoizations. Verified by 2300+ unit tests, Codex review, and on-device smoke test on a Pixel 7a.
The device-storage scan bakes each storage's `setupIncomplete` flag at scan time (from StorageSetupModule.isComplete()). If a scan ran before the storage permission was granted, that stale flag — and the permission-limited content — stuck in the cached analyzer data until the next manual scan. The StorageAnalyzer then kept showing "permissions missing", the storage card bounced to a Setup screen that immediately closed (setup was actually complete), and content drill-down was blocked. Observe the storage SetupModule in the Analyzer core and, when it transitions to complete while cached storage data still carries setupIncomplete, submit a fresh DeviceStorageScanTask so the flag clears and real content loads. The in-app setup flow already refreshes the module (SetupViewModel -> setupManager.refresh -> storageSetupModule.refresh; also SetupHealer), so the transition fires reliably. Loop-safe: after the rescan no DeviceStorage carries setupIncomplete and the setup state stays complete (distinctUntilChanged). Pre-existing bug (same transient-stale-state shape as PR #2474, which only fixed the dashboard setup card); not caused by the progress-decoupling refactor. Verified: app builds, 59 analyzer unit tests pass, and on a Pixel 7a the storage card no longer shows "permissions missing", tapping it drills into content categories (no Setup bounce), and full storage->category->content->subfolder drill-down works.
Brings in SharedResource teardown race hardening (#2453 follow-up).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
The entire UI layer has been rewritten from the ground up in Jetpack Compose with Material 3. Every screen now runs on the new UI stack — the legacy Fragment / XML View layer is gone.
Why?
Status: complete and ready to merge.
ViewModel4baseNavDisplay)mainand merges cleanly (no conflicts)See
COMPOSE_REWRITE_PLAN.mdin the branch for the full breakdown and remaining polish items.