Skip to content

General: User interface and user experience overhaul#2381

Merged
d4rken merged 492 commits into
mainfrom
compose-rewrite
Jun 22, 2026
Merged

General: User interface and user experience overhaul#2381
d4rken merged 492 commits into
mainfrom
compose-rewrite

Conversation

@d4rken

@d4rken d4rken commented Apr 14, 2026

Copy link
Copy Markdown
Member

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?

  • Higher UI fidelity and smoother animations
  • Faster feature development — new screens and UI tweaks take a fraction of the time
  • Easier to polish and iterate on details
  • Consistency with the sibling apps (butler, capod, bluemusic, octi)

Status: complete and ready to merge.

  • All screens converted to Compose (Onboarding, Dashboard, Settings, every cleaning tool, Picker, Setup, Support, …) — 57 screens, 62 ViewModels on the unified ViewModel4 base
  • All dashboard cards reimplemented with pixel-parity to the previous UI
  • Single-Activity navigation on Navigation3 (NavDisplay)
  • Zero Fragments and zero XML layouts remain in the codebase
  • Branch is current with main and merges cleanly (no conflicts)

See COMPOSE_REWRITE_PLAN.md in the branch for the full breakdown and remaining polish items.

@d4rken d4rken added enhancement New feature, request, improvement or optimization General UI/UX User Interface/Experience Chore labels Apr 17, 2026
d4rken added 26 commits May 13, 2026 10:19
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.
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
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.
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
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.
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.
d4rken added 27 commits June 20, 2026 21:15
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).
@d4rken d4rken marked this pull request as ready for review June 22, 2026 13:45
@d4rken d4rken merged commit b3fc742 into main Jun 22, 2026
13 checks passed
@d4rken d4rken deleted the compose-rewrite branch June 22, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature, request, improvement or optimization General UI/UX User Interface/Experience

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant