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
16 changes: 9 additions & 7 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,12 +414,15 @@ names the sharer.
### Selection

**Select** starts selection mode: the top bar turns `surface-container-high`
with close, "_n_ selected", select all and overflow. Checkboxes replace the
file icons 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; delete reads Delete permanently, and confirms, when the account turns
Trash off.
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
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
finished, and delete reads Delete, and confirms with the count, when the
account turns Trash off. Back ends selection before it leaves the folder. In
list-detail, the detail pane shows no file while rows are selected. Downloads
uses the same bar, checkboxes, tint and toolbar tiles.

### Menus

Expand Down Expand Up @@ -796,7 +799,6 @@ the navigation bar: **Welcome!** as a 40sp medium title on a 48sp line, then
counts elsewhere use the UI face.
- Transfer cards and Transfer actions show no peer count: the Kotlin SDK's
`Transfer` has no peers field.
- Files has no selection mode, so the actions sheet has no Select.
- The player has no cast button, and its error has no Download: downloads keep
the HLS rendition.
- The viewers have no Download: Downloads hold video and audio only.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ Mobile, on the emulator harness against the live API:
create folders, trash with Undo, delete permanently, search inside a folder,
played marks, and Continue watching for videos started on this device
- One actions sheet per item: Play or Resume, Download, Share, Open in VLC,
Mark as watched, Rename, Move, Exclusive link and delete
Select, Mark as watched, Rename, Move, Exclusive link and delete
- Files selection: move, download, mark watched or unwatched, and trash or
delete several items at once
- Tablets from 840 dp: the list beside the selected file's preview and facts
- Images, PDFs and text in an in-app viewer; other types open with another app
- Trash: days left per item, restore one or all, delete permanently, empty
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@ internal fun FilesBrowserState.reduceDelete(event: FilesBrowserEvent.DeleteEvent
}

private fun FilesBrowserState.delete(event: FilesBrowserEvent.Delete): FilesBrowserTransition {
val item = current.content.items().firstOrNull { it.id == event.itemId && it.id.value > 0L }
if (event.folderId != current.folder.id || item == null) {
val items = current.content.listedItems(event.itemIds)
if (event.folderId != current.folder.id || items == null) {
return FilesBrowserTransition(this, consumed = false)
}
val intent = FilesFolderOperationIntent.Delete(item.id, event.mode)
val intent = FilesFolderOperationIntent.Delete(items.map { it.id }, event.mode)
val started = startOperation(intent, FilesFolderOperationPhase.DELETING)
val effect = started.effect
return if (effect == null) {
started
} else {
val updated = started.state.current.copy(
deleteOutcome = FilesDeleteOutcome(effect.requestId, intent, item.name),
deleteOutcome = FilesDeleteOutcome(effect.requestId, intent, items.first().name),
moveOutcome = null,
)
started.copy(state = started.state.copy(stack = started.state.stack.replaceLast(updated)))
Expand Down Expand Up @@ -60,7 +60,7 @@ internal fun FilesBrowserState.deleteFinished(event: FilesBrowserEvent.DeleteFin
)
FilesBrowserTransition(
state = copy(stack = stack.replaceAt(index, updated), nextRequestValue = nextRequestValue + 1),
effect = FilesBrowserEffect.CheckDelete(outcome.intent.itemId, requestId),
effect = FilesBrowserEffect.CheckDelete(outcome.intent.itemIds, requestId),
)
}
}
Expand All @@ -73,15 +73,19 @@ private fun FilesBrowserState.deleteChecked(event: FilesBrowserEvent.DeleteCheck
val folder = stack.getOrNull(index)
val outcome = folder?.deleteOutcome
if (folder == null || outcome == null) return FilesBrowserTransition(this, consumed = false)
val failure = event.result.deleteReadFailure(outcome.intent.itemId)
val unavailable = failure is PutioFailure.ApiRejected &&
failure.statusCode == HTTP_NOT_FOUND && failure.httpStatusCode == HTTP_NOT_FOUND
return if (failure != null && !unavailable) {
val reads = outcome.intent.itemIds.zipReads(event.results)
.map { (id, result) -> id to result.deleteReadFailure(id) }
val remaining = reads.filterNot { (_, failure) -> failure.isNotFound }.mapTo(mutableSetOf()) { it.first }
val failure = reads.mapNotNull { (_, failure) -> failure?.takeUnless { it.isNotFound } }.verdict()
return if (failure != null) {
loadFailed(FilesBrowserEvent.LoadFailed(event.requestId, failure))
} else {
val requestId = FilesRequestId(nextRequestValue)
val updated = folder.copy(
deleteOutcome = outcome.copy(status = outcome.checkedStatus(unavailable)),
deleteOutcome = outcome.copy(
status = outcome.checkedStatus(unavailable = remaining.isEmpty()),
remainingIds = remaining,
),
operation = FilesFolderOperation.Loading(requestId, outcome.intent, FilesFolderOperationPhase.RELOADING),
needsReload = false,
)
Expand Down Expand Up @@ -110,6 +114,9 @@ private fun FilesBrowserState.trashUndone(outcome: FilesDeleteOutcome): FilesBro
return FilesBrowserTransition(copy(stack = stack.replaceAt(index, undone)))
}

private val PutioFailure?.isNotFound: Boolean
get() = this is PutioFailure.ApiRejected && statusCode == HTTP_NOT_FOUND && httpStatusCode == HTTP_NOT_FOUND

private fun PutioResult<FilesItem>.deleteReadFailure(expectedId: FilesItemId): PutioFailure? = when (this) {
is PutioResult.Failure -> failure
is PutioResult.Success -> if (value.id == expectedId) null else
Expand All @@ -127,7 +134,7 @@ public val FilesFolderOperation.pendingDelete: FilesFolderOperationIntent.Delete
get() = pendingIntent()

internal fun FilesBrowserState.isDeleteTargetBlocked(itemId: FilesItemId): Boolean = stack.any {
it.operation.pendingDelete?.itemId == itemId
it.operation.pendingDelete?.itemIds?.contains(itemId) == true
}

private const val HTTP_NOT_FOUND = 404
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,24 @@ public val FilesBrowserState.canStartMove: Boolean
stack.dropLast(1).all { it.operation == FilesFolderOperation.Idle }

private fun FilesBrowserState.move(event: FilesBrowserEvent.Move): FilesBrowserTransition {
val item = current.content.items().firstOrNull { it.id == event.itemId && it.id.value > 0L }
if (event.folderId != current.folder.id || item == null || !canStartMove) {
val items = current.content.listedItems(event.itemIds)
if (event.folderId != current.folder.id || items == null || !canStartMove) {
return FilesBrowserTransition(this, consumed = false)
}
val sameParent = event.destinationId == current.folder.id || event.destinationId == item.parentId
val invalidDestination = event.destinationId.value < 0L || sameParent || event.destinationId == item.id
val destination = event.destinationId
val invalidDestination = destination.value < 0L || destination == current.folder.id ||
items.any { destination == it.parentId || destination == it.id }
return if (invalidDestination) {
FilesBrowserTransition(this, consumed = false)
} else {
val intent = FilesFolderOperationIntent.Move(item.id, event.destinationId)
val intent = FilesFolderOperationIntent.Move(items.map { it.id }, destination)
val started = startOperation(intent, FilesFolderOperationPhase.MOVING)
val effect = started.effect
if (effect == null) {
started
} else {
val updated = started.state.current.copy(
moveOutcome = FilesMoveOutcome(effect.requestId, intent, item.name), deleteOutcome = null,
moveOutcome = FilesMoveOutcome(effect.requestId, intent, items.first().name), deleteOutcome = null,
)
// Either destination or source ancestors can have changed once the POST is submitted.
val ancestors = started.state.stack.dropLast(1).map {
Expand Down Expand Up @@ -70,7 +71,7 @@ internal fun FilesBrowserState.moveFinished(event: FilesBrowserEvent.MoveFinishe
)
FilesBrowserTransition(
copy(stack = stack.replaceAt(index, updated), nextRequestValue = nextRequestValue + 1),
FilesBrowserEffect.CheckMove(outcome.intent.itemId, requestId),
FilesBrowserEffect.CheckMove(outcome.intent.itemIds, requestId),
)
}
}
Expand All @@ -83,19 +84,22 @@ private fun FilesBrowserState.moveChecked(event: FilesBrowserEvent.MoveChecked):
val folder = stack.getOrNull(index)
val outcome = folder?.moveOutcome
if (folder == null || outcome == null) return FilesBrowserTransition(this, consumed = false)
val failure = event.result.moveReadFailure(outcome.intent.itemId)
val reads = outcome.intent.itemIds.zipReads(event.results)
val failure = reads.mapNotNull { (id, result) -> result.moveReadFailure(id) }.verdict()
return if (failure != null) {
loadFailed(FilesBrowserEvent.LoadFailed(event.requestId, failure))
} else {
val item = (event.result as? PutioResult.Success)?.value
val notMoved = reads.filter { (_, result) ->
(result as? PutioResult.Success)?.value?.parentId != outcome.intent.destinationId
}.mapTo(mutableSetOf()) { it.first }
val status = when {
!outcome.errors.isNullOrEmpty() -> FilesMoveStatus.REJECTED
item?.parentId == outcome.intent.destinationId -> FilesMoveStatus.MOVED
notMoved.isEmpty() -> FilesMoveStatus.MOVED
else -> FilesMoveStatus.STILL_PRESENT
}
val requestId = FilesRequestId(nextRequestValue)
val updated = folder.copy(
moveOutcome = outcome.copy(status = status),
moveOutcome = outcome.copy(status = status, remainingIds = notMoved),
operation = FilesFolderOperation.Loading(requestId, outcome.intent, FilesFolderOperationPhase.RELOADING),
needsReload = false,
)
Expand All @@ -118,9 +122,17 @@ private fun PutioResult<FilesItem>.moveReadFailure(expectedId: FilesItemId): Put
public val FilesFolderOperation.pendingMove: FilesFolderOperationIntent.Move?
get() = pendingIntent()

internal fun FilesMoveOutcome.afterFolderPage(page: FilesPage): FilesMoveOutcome =
if (status == FilesMoveStatus.MOVED && page.items.any { it.id == intent.itemId }) {
copy(status = FilesMoveStatus.STILL_PRESENT)
} else {
this
/**
* A page that lists a moved item again: the move left it here, whatever the check said. A refused
* move keeps its status and counts the item too.
*/
internal fun FilesMoveOutcome.afterFolderPage(page: FilesPage): FilesMoveOutcome {
val listed = page.items.mapNotNullTo(mutableSetOf()) { item -> item.id.takeIf { it in intent.itemIds } }
return when {
remainingIds.containsAll(listed) -> this
status == FilesMoveStatus.MOVED || status == FilesMoveStatus.STILL_PRESENT ->
copy(status = FilesMoveStatus.STILL_PRESENT, remainingIds = remainingIds + listed)
status == FilesMoveStatus.REJECTED -> copy(remainingIds = remainingIds + listed)
else -> this
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package io.putdotio.android.files

import io.putdotio.android.PutioFailure
import io.putdotio.android.PutioResult

internal fun FilesBrowserState.mutationSucceeded(
requestId: FilesRequestId,
): FilesBrowserTransition {
Expand Down Expand Up @@ -64,9 +67,32 @@ internal fun FilesFolderState.replaceFirstPage(
)
}

internal fun FilesDeleteOutcome.afterFolderPage(page: FilesPage): FilesDeleteOutcome =
if (status == FilesDeleteStatus.NO_LONGER_AVAILABLE && page.items.any { it.id == intent.itemId }) {
copy(status = FilesDeleteStatus.STILL_PRESENT)
/**
* A page that lists a deleted item again: it is still here, whatever the check said. An incomplete
* request keeps its status and counts the item too.
*/
internal fun FilesDeleteOutcome.afterFolderPage(page: FilesPage): FilesDeleteOutcome {
val listed = page.items.mapNotNullTo(mutableSetOf()) { item -> item.id.takeIf { it in intent.itemIds } }
return when {
remainingIds.containsAll(listed) -> this
status == FilesDeleteStatus.NO_LONGER_AVAILABLE || status == FilesDeleteStatus.STILL_PRESENT ->
copy(status = FilesDeleteStatus.STILL_PRESENT, remainingIds = remainingIds + listed)
status == FilesDeleteStatus.SKIPPED || status == FilesDeleteStatus.TOO_LARGE_FOR_TRASH ->
copy(remainingIds = remainingIds + listed)
else -> this
}
}

/** The failure a check reports: a rejected session first, since it decides whether the session stays. */
internal fun List<PutioFailure>.verdict(): PutioFailure? =
firstOrNull { it is PutioFailure.AuthenticationRequired } ?: firstOrNull()

/** One read per item, in order; a check that read some other number of items read nothing. */
internal fun List<FilesItemId>.zipReads(
results: List<PutioResult<FilesItem>>,
): List<Pair<FilesItemId, PutioResult<FilesItem>>> =
if (results.size == size) {
zip(results)
} else {
this
map { it to PutioResult.Failure(PutioFailure.Unexpected(IllegalStateException("Item reads did not match"))) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,22 @@ private fun effectFor(
is FilesFolderOperationIntent.Rename -> FilesBrowserEffect.Rename(intent.itemId, intent.name, requestId)
is FilesFolderOperationIntent.Move ->
if (phase == FilesFolderOperationPhase.MOVING) {
FilesBrowserEffect.Move(intent.itemId, intent.destinationId, requestId)
FilesBrowserEffect.Move(intent.itemIds, intent.destinationId, requestId)
} else {
FilesBrowserEffect.CheckMove(intent.itemId, requestId)
FilesBrowserEffect.CheckMove(intent.itemIds, requestId)
}
is FilesFolderOperationIntent.Delete ->
if (phase == FilesFolderOperationPhase.DELETING) {
FilesBrowserEffect.Delete(intent.itemId, intent.mode, requestId)
FilesBrowserEffect.Delete(intent.itemIds, intent.mode, requestId)
} else {
FilesBrowserEffect.CheckDelete(intent.itemId, requestId)
FilesBrowserEffect.CheckDelete(intent.itemIds, requestId)
}
}
}

/** Each of [itemIds], once, as the listing has it; null unless every one is listed and real. */
internal fun FilesContent.listedItems(itemIds: List<FilesItemId>): List<FilesItem>? {
val listed = items().filter { it.id.value > 0L }.associateBy { it.id }
val found = itemIds.mapNotNull(listed::get)
return found.takeIf { it.isNotEmpty() && it.size == itemIds.size && itemIds.toSet().size == itemIds.size }
}
Loading