Pantrix UI

File Drop

Somewhere to give the app a file: a zone with a dashed edge that opens the platform's picker when it is pressed, and takes the files dropped on it from the platform's files, another app or another window.

import com.pantrixui.components.elements.filedrop.PantrixFileDrop

Examples

Attachments: kinds, size and rows

A form's attachments: PDF or pictures, several, each at most 10 MB. Every file taken becomes a row and gets a bar that fills as if it were being sent; the remove button deletes the row. The ones not taken are written in the supporting text with their reasons.

val attachments = remember { mutableStateListOf<PantrixFile>() }
val sent = remember { mutableStateMapOf<PantrixFile, Float>() }
var refused by remember { mutableStateOf(emptyList<RejectedFile>()) }
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
    PantrixFileDrop(
        onFiles = { files ->
            attachments += files
            refused = emptyList()
        },
        accept = listOf(PantrixFileType.Pdf, PantrixFileType.Images),
        multiple = true,
        maxSize = TEN_MEGABYTES,
        onRejected = { refused = it },
        supportingText = if (refused.isEmpty()) "PDF or a picture, each at most 10 MB" else reasonOf(refused),
        error = refused.isNotEmpty(),
        tagKey = "attachments"
    )
    attachments.forEach { file ->
        key(file) {
            SendingLater(file, sent)
            PantrixFileItem(
                name = file.name,
                size = file.size,
                onRemove = {
                    attachments -= file
                    sent -= file
                },
                progress = sent[file]?.takeIf { it < 1f },
                tagKey = file.name
            )
        }
    }
}

@Composable
private fun SendingLater(
    file: PantrixFile,
    sent: MutableMap<PantrixFile, Float>
) {
    LaunchedEffect(file) {
        if (file in sent) return@LaunchedEffect
        repeat(STEPS + 1) { step ->
            sent[file] = step / STEPS.toFloat()
            delay(STEP_MILLIS)
        }
    }
}

private fun reasonOf(refused: List<RejectedFile>): String = refused.groupBy { it.reason }.entries.joinToString(" · ") {
    val names = it.value.joinToString { rejected -> rejected.file.name }
    when (it.key) {
        FileRejection.Type -> "This kind is not taken: $names"
        FileRejection.Size -> "Too large: $names"
        FileRejection.Count -> "Only one file is taken: $names"
    }
}

private const val TEN_MEGABYTES = 10_000_000L

private const val STEPS = 10

private const val STEP_MILLIS = 150L

One picture and the reason it was refused

One picture, at most 2 MB: a profile picture. On a phone the photo picker opens. The chosen picture is read and shown; a larger one, or a file that is no picture, is refused, and why is written under the zone as an error.

var avatar by remember { mutableStateOf<PantrixFile?>(null) }
var refused by remember { mutableStateOf<RejectedFile?>(null) }
var picture by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(avatar) {
    // The bytes are read only when asked for: here, once the zone's work is done.
    picture = avatar?.let { file -> runCatching { file.readBytes().decodeToImageBitmap() }.getOrNull() }
}
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
    PantrixFileDrop(
        onFiles = { files ->
            avatar = files.single()
            refused = null
        },
        accept = listOf(PantrixFileType.Images),
        maxSize = TWO_MEGABYTES,
        onRejected = { refused = it.first() },
        label = "Choose a profile picture",
        supportingText = refused?.let { reasonOf(listOf(it)) } ?: "PNG, JPEG or WebP, at most 2 MB",
        error = refused != null,
        tagKey = "avatar"
    )
    picture?.let {
        PantrixImage(bitmap = it, contentDescription = "The chosen picture", modifier = Modifier.size(PICTURE_SIZE))
    }
    avatar?.let { PantrixFileItem(name = it.name, size = it.size, onRemove = { avatar = null }, tagKey = "avatar") }
}

private fun reasonOf(refused: List<RejectedFile>): String = refused.groupBy { it.reason }.entries.joinToString(" · ") {
    val names = it.value.joinToString { rejected -> rejected.file.name }
    when (it.key) {
        FileRejection.Type -> "This kind is not taken: $names"
        FileRejection.Size -> "Too large: $names"
        FileRejection.Count -> "Only one file is taken: $names"
    }
}

private val PICTURE_SIZE = 96.dp

private const val TWO_MEGABYTES = 2_000_000L

The zone's own words, colours, and disabled

The zone's own words and colours: the caller writes its first line, and its fill and edge are the app's own colour. While the switch is off the zone takes nothing: it is not pressed, what is dropped on it is not taken, and it is drawn faint.

var open by rememberSaveable { mutableStateOf(true) }
var taken by remember { mutableStateOf(0) }
val own = ownColors()
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
    PantrixSwitchButton(checked = open, onCheckedChange = { open = it }, label = "The zone takes files", tagKey = "accepting")
    PantrixFileDrop(
        onFiles = { taken += it.size },
        multiple = true,
        label = "Drop your reports here",
        enabled = open,
        colors = FileDropColors(container = own.container, border = own.color, action = own.color),
        tagKey = "reports"
    )
    PantrixText(
        text = "Files taken: $taken",
        color = PantrixTheme.colors.textSecondary,
        tagKey = "taken"
    )
}

A low area and dense rows in their own style

In their own style: FileDropStyle gives the drop area's height, its dashed line and its corners, FileItemStyle a file row's height and its progress line. Every field not given is the platform's preset.

val files = remember { mutableStateListOf<PantrixFile>() }
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.xs)) {
    PantrixFileDrop(
        onFiles = { files += it },
        multiple = true,
        style = FileDropStyle(minHeight = 72.dp, shape = ShapeRole.Large, dashLength = 4.dp, dashGap = 4.dp),
        tagKey = "low"
    )
    files.forEach { file ->
        key(file) {
            PantrixFileItem(
                name = file.name,
                size = file.size,
                onRemove = { files -= file },
                style = FileItemStyle(minHeight = 40.dp, verticalPadding = 4.dp, progressHeight = 2.dp),
                tagKey = file.name
            )
        }
    }
}

API

onFiles

Somewhere to give the app a file: a zone with a dashed edge that opens the platform's picker when it is pressed, and takes the files dropped on it from the platform's files, another app or another window.

ParameterTypeDefaultWhat it is
onFiles(List<PantrixFile>) -> Unithanded the files the zone took, at least one, in the order they came.
modifierModifierModifierthe zone's modifier, with its supporting text. The zone fills the width it is given.
acceptList<PantrixFileType>listOf(PantrixFileType.Any)the kinds of file the zone takes. The picker shows only these, where the platform's can filter.
multipleBooleanfalsewhether the zone takes more than one file at a time.
maxSizeLong?nullthe most bytes a file the zone takes may have, or null for any size.
onRejected(List<RejectedFile>) -> Unit{}handed the files the zone did not take, each with why, at least one.
labelString?nullwhat the zone says first, in place of the platform's words.
supportingTextString?nulla line under the zone: what it takes and how large, or what was wrong.
enabledBooleantruewhether the zone takes anything: a press or a drop. A zone that does not is drawn faint.
errorBooleanfalsewhether the zone shows that what it was given was not right: its edge and its supporting text in the danger colour. A screen reader hears the supporting text as the error.
pickerFilePickerrememberFilePicker()what a press opens: the platform's own picker, or a test's.
interactionSourceMutableInteractionSource?nullthe zone's interactions: its press, its hover and its focus.
styleFileDropStyle?nullthis call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform.
colorsFileDropColors?nullthis zone's own colours: the ones it sets, with the theme's for the rest.
tagKeyString?nullthe zone's automation key: it is FileDrop_<tagKey>.

FileDropStyle

PantrixFileDrop's geometry: the zone, its dashed edge and what stands in it.

ParameterTypeDefaultWhat it is
shapeShapeSpecShapeSpec.UnspecifiedThe zone's corners: a step of the brand's shape scale, or a fixed shape.
minHeightDpDp.UnspecifiedThe least height of the zone. A larger type makes it taller.
paddingDpDp.UnspecifiedThe room between the zone's edge and what stands in it.
borderWidthDpDp.UnspecifiedThe width of the dashed edge.
dashLengthDpDp.UnspecifiedThe length of each of the edge's dashes.
dashGapDpDp.UnspecifiedThe room between two dashes.
iconSizeDpDp.UnspecifiedThe width and height of the icon over the zone's words.
gapDpDp.UnspecifiedBetween the icon and the words, and between the two lines of words.

FileDropColors

What a PantrixFileDrop is painted with, in place of the theme's colours: a colour that is set is used, and one that is left out stays the theme's.

ParameterTypeDefaultWhat it is
containerColorColor.UnspecifiedThe zone's fill. The theme's surface.
contentColorColor.UnspecifiedThe zone's first line and its icon. The theme's textPrimary, or the colour a role's fill given as container gives what stands on it.
actionColorColor.UnspecifiedThe second line, the press that opens the picker, as a link is. The primary role's color on the theme's own grounds, and content on another fill.
borderColorColor.UnspecifiedThe dashed edge. The theme's border, borderHover under a pointer, on its own grounds; a share of content on another fill, as a line on a fill is.
overContainerColorColor.UnspecifiedThe zone's fill while something is dragged over it. The primary role's container.
overContentColorColor.UnspecifiedWhat is written in it then. The primary role's onContainer.
overBorderColorColor.UnspecifiedThe edge then. The primary role's color.
errorColorColor.UnspecifiedThe edge and the supporting text of a zone that shows an error. The danger role's color.
supportingColorColor.UnspecifiedThe supporting text under the zone. The theme's textSecondary.

Accessibility