diff --git a/.claude/skills/verify-putio-android/features/files-root-and-folders.md b/.claude/skills/verify-putio-android/features/files-root-and-folders.md index d51f1871..14db7161 100644 --- a/.claude/skills/verify-putio-android/features/files-root-and-folders.md +++ b/.claude/skills/verify-putio-android/features/files-root-and-folders.md @@ -53,8 +53,8 @@ Preconditions: - Files keeps the listing it loaded. A fixture created after the app opened the folder stays missing until `ui.sh refresh`; `scroll-to` alone swipes past where it would be. -- A folder row has a chevron and no overflow button; its actions open with - a long-press. Files rows carry `Actions for `. +- Rows with actions, folders included, carry an `Actions for ` + overflow; a long-press starts selection instead of opening them. - A row's name is cut in the middle on screen; the XML holds the full name, so `--text` matches it. - Count and size come from put.io's total for the folder, not the rows on diff --git a/.claude/skills/verify-putio-android/features/files-selection.md b/.claude/skills/verify-putio-android/features/files-selection.md index 28593377..1e71d477 100644 --- a/.claude/skills/verify-putio-android/features/files-selection.md +++ b/.claude/skills/verify-putio-android/features/files-selection.md @@ -8,10 +8,9 @@ Preconditions: - Doctor passes. The run's root holds `Target folder` and three small uploaded files `a-.txt`, `b-.txt`, `c-.txt`. -- **Enter.** Open the run's folder, then the sheet of `a-.txt`, and tap - `Select`. Run `$S/ui.sh tap $SERIAL --desc "Actions for a-$RUN.txt"` and - `$S/ui.sh tap $SERIAL --text "Select"`. The bar shows `1 selected`. Capture - `select-enter`. +- **Enter.** Open the run's folder and long-press `a-.txt`. Run + `$S/ui.sh long-press $SERIAL --text "a-$RUN.txt"`. The bar shows + `1 selected`. Capture `select-enter`. The sheet's `Select` does the same. - **Toggle.** Tap `b-.txt` and `c-.txt`. Run `$S/ui.sh tap $SERIAL --text "b-$RUN.txt"` and the same for `c`. The bar shows `3 selected`; tapping one again drops it to `2 selected`. @@ -22,7 +21,7 @@ Preconditions: - **Trash.** Intend `trash `. Select the remaining file and tap `Trash`. The snackbar reads `Moved to Trash` with Undo. Tap Undo and adopt the id, or delete it from Trash by id as in [Item actions](./item-actions.md); then settle. -- **Exit.** Select a file again from its sheet, then press Back. Run `$S/ui.sh key $SERIAL BACK`. The +- **Exit.** Long-press a file again, then press Back. Run `$S/ui.sh key $SERIAL BACK`. The bar leaves and the folder stays. - **Proof.** Capture the bar, the picker, the snackbar and the CLI read-back. diff --git a/.claude/skills/verify-putio-android/features/item-actions.md b/.claude/skills/verify-putio-android/features/item-actions.md index 868d241e..dc04416d 100644 --- a/.claude/skills/verify-putio-android/features/item-actions.md +++ b/.claude/skills/verify-putio-android/features/item-actions.md @@ -14,8 +14,9 @@ Preconditions: `$S/ui.sh tap $SERIAL --desc "Actions for note-$RUN.txt"`. The sheet shows the full name, `Rename`, `Move` and `Move to trash` (or `Delete permanently` with Trash off). Capture `actions-open`. -- **Open from long-press.** Press Back, then run - `$S/ui.sh long-press $SERIAL --text "note-$RUN.txt"`. The same sheet opens. +- **Long-press selects.** Press Back, then run + `$S/ui.sh long-press $SERIAL --text "note-$RUN.txt"`. The selection bar + shows `1 selected` and no sheet opens. Press Back to end it. - **Rename.** Run `$S/fixture.sh intend $RUN "rename "`. Tap `Rename`; the dialog's field selects `note-` and keeps `.txt`. Type a new stem and tap the dialog's `Rename` button, the last node with that @@ -35,8 +36,8 @@ Preconditions: the run's root. The snackbar reads `Copied “” to .` Adopt the copy's id and settle. With nothing shared, report `actions-copy` unreachable. -- **Trash.** With `trash_enabled` true: intend `trash `. Long-press `Inner folder` to open its - sheet. Run `$S/ui.sh long-press $SERIAL --text "Inner folder"`, then tap +- **Trash.** With `trash_enabled` true: intend `trash `. Open the sheet of `Inner folder` from + its overflow. Run `$S/ui.sh tap $SERIAL --desc "Actions for Inner folder"`, then tap `Move to trash`. The snackbar reads `Moved to Trash` and the row leaves. Remove that one id from Trash: `PUTIO_CLI_PROFILE=devs-auto putio sdk call --operation trash.delete --args '[{"file_ids":[]}]' --execute`, diff --git a/DESIGN.md b/DESIGN.md index 30507dba..aa41ed21 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -389,12 +389,13 @@ start padding: - Meta: size in mono, `·`, date. Then, only when true: a grey outline eye when the video or audio was played (one glyph, started or finished alike), a yellow arrow-circle-down when the file is on this device. -- Trailing: a chevron for folders, an overflow button for files. +- Trailing: an overflow button for any row with actions; a folder without + them shows a chevron. - No progress bars or percentages in file rows. -Tap opens the item. -Long-press opens the same actions sheet as the overflow button. Downloads is -the exception: long-press starts selection with that row selected. +Tap opens the item. Long-press starts selection with that row selected, in +Files and Downloads. Where a list has no selection, as in Search, long-press +opens the same actions sheet as the overflow button. ### Item actions @@ -413,9 +414,9 @@ names the sharer. ### Selection -**Select** starts selection mode: the top bar turns `surface-container-high` +Long-press or the sheet's **Select** starts selection mode: the top bar turns `surface-container-high` with close, "_n_ selected" and select all. Checkboxes replace the file icons, -the trailing chevron and overflow leave, and selected rows take a 9% yellow +the trailing overflow or chevron leaves, and selected rows take a 9% yellow tint. A floating toolbar above the nav bar holds Move, Download, Watched and the delete action. Each action shows only when every selected item allows it, under the same rules as the sheet; Watched reads Unwatched when every file is @@ -516,7 +517,7 @@ Don't translate iOS: | ----------------------- | ------------------------------------------------------ | | Inset-grouped list | Segmented list groups under small labels | | Action sheet | `ModalBottomSheet` for item actions | -| Long-press context menu | Long-press opens the actions sheet (Downloads selects) | +| Long-press context menu | Long-press selects; the overflow opens the actions | | Toolbar plus | Extended FAB | | Text back-label | System back gesture and a top-bar arrow | | `UISwitch` | The compact neutral switch above | diff --git a/README.md b/README.md index d4e9f4f7..d7b2398f 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Mobile, on the emulator harness against the live API: - OAuth sign-in, with the session in Android secure storage - Files, Search, History, Transfers, Downloads, Trash and Account, with adaptive layouts for tablets -- Item actions, multi-select, public links and in-app image, PDF and text - viewers +- Item actions from the overflow, multi-select from a long-press, public + links and in-app image, PDF and text viewers - Video and background audio playback with resume, tracks, subtitles, picture-in-picture and positions saved to put.io - Offline downloads with offline playback, also after a start with no diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesDeleteTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesDeleteTest.kt index ba81b5bf..ec6ab6bb 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesDeleteTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesDeleteTest.kt @@ -6,14 +6,13 @@ import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule -import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextReplacement -import androidx.compose.ui.test.performTouchInput import androidx.test.espresso.Espresso import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -176,8 +175,10 @@ class AuthenticatedFilesDeleteTest { } private fun confirmOrCancel(fixture: DeleteProofFixture, name: String, cancel: Boolean) { - // Folders have no overflow button; a long-press opens the same sheet for any row. - compose.onNode(row(MOBILE_FILES_LIST_TAG, name)).performTouchInput { longClick() } + row(MOBILE_FILES_LIST_TAG, name) + compose.onNodeWithContentDescription( + compose.activity.getString(R.string.mobile_files_actions, name), + ).performClick() val action = if (fixture.expectedTrashEnabled) R.string.mobile_files_trash else R.string.mobile_files_delete_permanently val actionLabel = compose.activity.getString(action) diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesMoveTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesMoveTest.kt index 3e020fa8..6f691540 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesMoveTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesMoveTest.kt @@ -7,14 +7,13 @@ import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule -import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextReplacement -import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import io.putdotio.android.auth.MobileAuthState @@ -192,8 +191,9 @@ class AuthenticatedFilesMoveTest { private fun cancelMove(f: MoveProofFixture, runtime: MobileOAuthRuntime) { val before = compose.onNode(row(f.folderName)).fetchSemanticsNode().boundsInRoot - // Folders have no overflow button; a long-press opens the same sheet. - compose.onNode(row(f.folderName)).performTouchInput { longClick() } + compose.onNodeWithContentDescription( + compose.activity.getString(R.string.mobile_files_actions, f.folderName), + ).performClick() openPicker() pickFolder(f.containerId) pickFolder(f.destinationId) @@ -223,7 +223,9 @@ class AuthenticatedFilesMoveTest { private fun submit(name: String, destinationPath: List) { row(name) - compose.onNode(row(name)).performTouchInput { longClick() } + compose.onNodeWithContentDescription( + compose.activity.getString(R.string.mobile_files_actions, name), + ).performClick() openPicker() destinationPath.forEach(::pickFolder) compose.onNodeWithTag(MOBILE_FILES_MOVE_HERE_TAG).performClick() diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesRenameTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesRenameTest.kt index d1b8020b..be23f59a 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesRenameTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/AuthenticatedFilesRenameTest.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule -import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -14,7 +13,6 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextReplacement -import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import io.putdotio.android.auth.MobileAuthState @@ -104,7 +102,7 @@ class AuthenticatedFilesRenameTest { requireSession(runtime, session) openContainer(fixture) renameThroughOverflow(fixture, runtime, session) - cancelThroughLongPress(fixture, runtime, session) + cancelThroughOverflow(fixture, runtime, session) requireSession(runtime, session) apiCheck("final identity") { check(runtime.putioClient.account.getInfo().userId == fixture.expectedAccountId) @@ -148,14 +146,16 @@ class AuthenticatedFilesRenameTest { } } - private fun cancelThroughLongPress( + private fun cancelThroughOverflow( fixture: RenameProofFixture, runtime: MobileOAuthRuntime, session: MobileAuthState.SignedIn, ) { requireSession(runtime, session) val cancelRow = waitForRow(MOBILE_FILES_LIST_TAG, fixture.cancelOriginalName) - compose.onNode(cancelRow).performTouchInput { longClick() } + compose.onNodeWithContentDescription( + compose.activity.getString(R.string.mobile_files_actions, fixture.cancelOriginalName), + ).performClick() compose.onNodeWithText(compose.activity.getString(R.string.mobile_files_rename)).performClick() requireEditorName(fixture.cancelOriginalName) val draft = fixture.cancelOriginalName + " cancelled draft" diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt index b0a0b78f..999113a0 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt @@ -53,8 +53,9 @@ internal const val MOBILE_FILES_DOWNLOAD_TAG = "mobile-files-download" /** * A file row: a fixed 64dp line with the type glyph, the name cut in the middle on one line, and - * meta (size in mono, date, then the played eye and the on-device mark when true). Folders end in - * a chevron and files in an overflow button; long-press opens the same actions either way. In + * meta (size in mono, date, then the played eye and the on-device mark when true). A row with + * actions ends in an overflow button, and a folder without them in a chevron. Long-press runs + * [onLongClick], which starts selection where the list has one, else opens the actions. In * selection a checkbox takes the glyph's place, the trailing slot empties, and a tap toggles the row. */ @Composable @@ -65,6 +66,9 @@ internal fun MobileFilesRow( onClickLabel: String? = null, onActions: (() -> Unit)? = null, actionsEnabled: Boolean = true, + /** Long-press, when it does something other than open the actions: starting selection. */ + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, downloadStatus: DownloadStatus? = null, /** The file an outside open came for; it has no screen of its own, so its row stands out. */ highlighted: Boolean = false, @@ -84,35 +88,15 @@ internal fun MobileFilesRow( // Touch never focuses a row; a keyboard-focused one is outlined so it reads at a glance. var focused by remember { mutableStateOf(false) } val outline = MaterialTheme.colorScheme.primary - val interaction = if (onActions != null && actionsEnabled) { - Modifier.combinedClickable( - onClickLabel = onClickLabel, - role = Role.Button, - onClick = onClick ?: onActions, - onLongClickLabel = actionsLabel, - onLongClick = onActions, - ) - } else if (onClick != null) { - Modifier.clickable( - onClickLabel = onClickLabel, - role = Role.Button, - onClick = onClick, - ) - } else { - Modifier - } + val interaction = Modifier.rowInteraction( + onClick = onClick, + onClickLabel = onClickLabel, + onActions = onActions?.takeIf { actionsEnabled }, + onLongClick = onLongClick, + onLongClickLabel = if (onLongClick != null) onLongClickLabel else actionsLabel, + ) val trailing: (@Composable () -> Unit)? = when { - item.isFolder -> { - { - Icon( - painter = painterResource(R.drawable.ic_ph_caret_right), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 12.dp).size(FILES_CHEVRON_SIZE), - ) - } - } onActions != null -> { { IconButton( @@ -129,6 +113,16 @@ internal fun MobileFilesRow( } } } + item.isFolder -> { + { + Icon( + painter = painterResource(R.drawable.ic_ph_caret_right), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 12.dp).size(FILES_CHEVRON_SIZE), + ) + } + } else -> null } @@ -253,6 +247,32 @@ private fun MobileFilesMeta(item: FilesItem, onDevice: Boolean, place: MobileFil } } +/** + * Tap runs [onClick], or opens the actions when there is nothing else to do; long-press runs + * [onLongClick] where the list has one, else opens the actions. + */ +private fun Modifier.rowInteraction( + onClick: (() -> Unit)?, + onClickLabel: String?, + onActions: (() -> Unit)?, + onLongClick: (() -> Unit)?, + onLongClickLabel: String?, +): Modifier { + val longClick = onLongClick ?: onActions + val tap = onClick ?: onActions + return when { + longClick != null -> combinedClickable( + onClickLabel = onClickLabel, + role = Role.Button, + onClick = tap ?: {}, + onLongClickLabel = onLongClickLabel, + onLongClick = longClick, + ) + tap != null -> clickable(onClickLabel = onClickLabel, role = Role.Button, onClick = tap) + else -> this + } +} + private fun Modifier.focusOutline(focused: Boolean, color: Color): Modifier = if (focused) border(FILES_FOCUS_OUTLINE, color) else this diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt index 113bb337..aa0fa323 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt @@ -334,7 +334,6 @@ private fun MobileRefreshableFilesContent( } } } - val listHeaders = headers.unlessSelecting(selecting) val isRefreshing = operation is FilesFolderOperation.Loading && operation.intent == FilesFolderOperationIntent.Refresh @@ -398,11 +397,11 @@ private fun MobileRefreshableFilesContent( state.stack.size, state.current.folder.id.value, state.current.viewportGeneration, - listHeaders.itemCount, + headers.itemCount, ) { MobileFilesList( content = content, - headers = listHeaders, + headers = headers, onPlayEntry = { onPlayMedia(it.toFilesItem()) }, revealItemId = state.current.revealItemId, pagingEnabled = operation == FilesFolderOperation.Idle, @@ -418,10 +417,11 @@ private fun MobileRefreshableFilesContent( focusedItemId = focusedItemId.afterFocusChange(id.value, focused) }, fileDragOut = fileDragOut, - // A touch drag starts after the long press opened the sheet; the drag replaces it. + // A touch drag starts after the long press; it closes any sheet that press opened. onDragStarted = dismissSelection, fileOpening = fileOpening, selection = selection.listSelection(state, toolbarClearance), + onStartSelection = selection.starter { currentState }, ) } @@ -827,6 +827,8 @@ private fun MobileFilesList( onDragStarted: () -> Unit, fileOpening: MobileFilesOpening?, selection: MobileFilesListSelection? = null, + /** Long-press starts selection with the row; null leaves long-press opening the row's actions. */ + onStartSelection: ((FilesItem) -> Unit)? = null, ) { val viewport = content.viewport // The viewport counts rows; headers sit above them and show again when the list is back at its top. @@ -878,7 +880,7 @@ private fun MobileFilesList( // toolbar, however tall large fonts make it. contentPadding = selection?.let { PaddingValues(top = 8.dp, bottom = it.bottomClearance) } ?: PaddingValues(), ) { - mobileFilesHeaderItems(headers, onPlayEntry) + mobileFilesHeaderItems(headers, onPlayEntry, hidden = selection != null) itemsIndexed( items = content.items, key = { _, item -> item.id.value }, @@ -902,6 +904,9 @@ private fun MobileFilesList( downloadStatus = downloads.rowStatus(item.id), onActions = if (hasActions(item)) { { onActions(item) } } else null, actionsEnabled = actionsEnabled, + onLongClick = onStartSelection?.takeIf { actionsEnabled && item.id.value > 0L } + ?.let { start -> { start(item) } }, + onLongClickLabel = stringResource(R.string.mobile_files_select), onClick = rowClick(item, fileOpening, onEvent, onPlayMedia) .takeUnless { item.id in pendingItemIds }, onClickLabel = rowClickLabel(item, fileOpening), @@ -1043,12 +1048,16 @@ private fun MobileFilesListRow( MobileFileDragShadow(item.name, density.density, density.fontScale, shadowBackground, shadowForeground) if (fileDragOut?.start(view, item, shadow) == true) onDragStarted() } - row( - Modifier - .then(entryFocus?.let { Modifier.focusRequester(it) } ?: Modifier) - .onFocusChanged { onFocusChanged(it.hasFocus) } - .mobileFileDragSource(fileDragOut != null && !item.isFolder && item.id.value > 0L) { startDrag.value() }, - ) + // The drag source wraps the row: the long-press before a touch drag starts selection, which swaps the + // row for its selectable form, and the gesture has to outlive that swap. + val draggable = fileDragOut != null && !item.isFolder && item.id.value > 0L + Box(Modifier.mobileFileDragSource(draggable) { startDrag.value() }) { + row( + Modifier + .then(entryFocus?.let { Modifier.focusRequester(it) } ?: Modifier) + .onFocusChanged { onFocusChanged(it.hasFocus) }, + ) + } } private fun FilesFolderOperation.pendingItemIds(): Set { diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesSelection.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesSelection.kt index fb25e35e..39f7b717 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesSelection.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesSelection.kt @@ -412,9 +412,6 @@ internal class MobileFilesListSelection( internal fun FilesSelection?.isActiveIn(state: FilesBrowserState): Boolean = this?.isActive(state) == true /** Selection keeps to the rows: Continue watching and the folder's count line step aside. */ -internal fun MobileFilesHeaders.unlessSelecting(selecting: Boolean): MobileFilesHeaders = - if (selecting) MobileFilesHeaders() else this - /** The room the list leaves under its last row for a toolbar [heightPx] tall. */ @Composable internal fun selectionToolbarClearance(heightPx: Int): Dp = @@ -439,6 +436,10 @@ internal fun MobileFilesItemActions.selectingIn( state: () -> FilesBrowserState, ): MobileFilesItemActions = selection?.let { withSelect { item -> it.start(state(), item) } } ?: this +/** A row's long-press, which starts selection in the folder [state] reads, when there is a selection. */ +internal fun FilesSelection?.starter(state: () -> FilesBrowserState): ((FilesItem) -> Unit)? = + this?.let { selection -> { item -> selection.start(state(), item) } } + /** A file not downloaded yet, or whose download failed or went missing, can start one. */ private val DownloadStatus?.downloadable: Boolean get() = this == null || this is DownloadStatus.Failed || this == DownloadStatus.Missing diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesStates.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesStates.kt index 34f41e28..7eb1cc4b 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesStates.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesStates.kt @@ -102,20 +102,26 @@ internal data class MobileFilesHeaders( } } +/** + * The items above the first row. [hidden] in selection draws them empty but keeps them in the list, + * so entering selection neither shifts row indices nor cancels the long-press that started it. + */ internal fun LazyListScope.mobileFilesHeaderItems( headers: MobileFilesHeaders, onPlay: (ContinueWatchingEntry) -> Unit, + hidden: Boolean = false, ) { + fun header(key: String, content: @Composable () -> Unit) = item(key = key) { if (!hidden) content() } if (headers.continueWatching.isNotEmpty()) { - item(key = "continue-watching-label") { + header("continue-watching-label") { MobileFilesSectionLabel(stringResource(R.string.mobile_files_continue_watching)) } - item(key = "continue-watching") { MobileContinueWatchingRow(headers.continueWatching, onPlay) } - item(key = "all-files-label") { + header("continue-watching") { MobileContinueWatchingRow(headers.continueWatching, onPlay) } + header("all-files-label") { MobileFilesSectionLabel(stringResource(R.string.mobile_files_all_files), top = 12.dp) } } else { - headers.summary?.let { summary -> item(key = "folder-summary") { MobileFolderSummaryLine(summary) } } + headers.summary?.let { summary -> header("folder-summary") { MobileFolderSummaryLine(summary) } } } } diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileFilesSelectionTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileFilesSelectionTest.kt index 39d36f8b..7f720f53 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileFilesSelectionTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileFilesSelectionTest.kt @@ -88,6 +88,21 @@ class MobileFilesSelectionTest { compose.runOnIdle { assertEquals(FilesBrowserEvent.NavigateBack, events.last()) } } + @Test + fun longPressStartsSelectionWithThatRowAndFoldersKeepTheirActionsInTheOverflow() { + setShell() + + compose.onNodeWithText(SERIES.name).performTouchInput { longClick() } + compose.onNodeWithText("1 selected").assertExists() + compose.onNodeWithText("Select").assertDoesNotExist() + assertTrue(events.none { it is FilesBrowserEvent.OpenFolder }) + compose.runOnIdle { backOwner.onBackPressedDispatcher.onBackPressed() } + + compose.onNodeWithContentDescription("Actions for ${SERIES.name}").performClick() + compose.onNodeWithText("Rename").assertExists() + compose.onNodeWithTag(MOBILE_FILES_SELECTION_BAR_TAG).assertDoesNotExist() + } + @Test fun trashSendsEveryCheckedRowInOneDeleteAndEndsTheSelection() { setShell() @@ -213,7 +228,7 @@ class MobileFilesSelectionTest { } private fun selectFromTheSheet(item: FilesItem) { - compose.onNodeWithText(item.name).performTouchInput { longClick() } + compose.onNodeWithContentDescription("Actions for ${item.name}").performClick() compose.onNodeWithText("Select").performClick() compose.onNodeWithText("Select").assertDoesNotExist() } diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileShellTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileShellTest.kt index 4991ba27..74f72857 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileShellTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileShellTest.kt @@ -280,7 +280,7 @@ class MobileShellTest { } compose.onNodeWithTag(MOBILE_NAV_BAR_TAG).assertExists() - compose.onNodeWithText("movie.mkv").performTouchInput { longClick() } + compose.onNodeWithContentDescription("Actions for movie.mkv").performClick() compose.onNodeWithTag(MOBILE_FILES_SHARE_ACTION_TAG).assertIsDisplayed().performClick() compose.runOnIdle { assertEquals(listOf(FilesItemId(9L)), shared.map(FilesItem::id)) }