Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`.
- Rows with actions, folders included, carry an `Actions for <name>`
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ Preconditions:
- Doctor passes. The run's root holds `Target folder` and three small
uploaded files `a-<run>.txt`, `b-<run>.txt`, `c-<run>.txt`.

- **Enter.** Open the run's folder, then the sheet of `a-<run>.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-<run>.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-<run>.txt` and `c-<run>.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`.
Expand All @@ -22,7 +21,7 @@ Preconditions:
- **Trash.** Intend `trash <id>`. 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.

Expand Down
9 changes: 5 additions & 4 deletions .claude/skills/verify-putio-android/features/item-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file id>"`. Tap
`Rename`; the dialog's field selects `note-<run>` and keeps `.txt`. Type a
new stem and tap the dialog's `Rename` button, the last node with that
Expand All @@ -35,8 +36,8 @@ Preconditions:
the run's root. The snackbar reads `Copied “<name>” to <folder>.` Adopt
the copy's id and settle. With nothing shared, report `actions-copy`
unreachable.
- **Trash.** With `trash_enabled` true: intend `trash <folder id>`. 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 <folder id>`. 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":[<id>]}]' --execute`,
Expand Down
15 changes: 8 additions & 7 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
altaywtf marked this conversation as resolved.

### Item actions

Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -223,7 +223,9 @@ class AuthenticatedFilesMoveTest {

private fun submit(name: String, destinationPath: List<Long>) {
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +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.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.putdotio.android.auth.MobileAuthState
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
78 changes: 49 additions & 29 deletions mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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
}

Expand Down Expand Up @@ -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

Expand Down
Loading