Calcula una propina personalizada

1. Antes de comenzar

En este codelab, usarás el código de la solución del codelab Introducción al estado en Compose para compilar una calculadora interactiva de propinas que puede calcular y redondear automáticamente un importe de propina cuando ingresas el importe de la factura y el porcentaje de propina. Puedes ver la app final en esta imagen:

d8e768525099378a.png

Requisitos previos

  • Haber completado el codelab Introducción al estado en Compose
  • Poder agregar los elementos componibles Text y TextField a una app
  • Conocer la función remember(), el estado, la elevación de estado y la diferencia entre las funciones de componibilidad con y sin estado

Qué aprenderás

  • Cómo agregar un botón de acción a un teclado virtual
  • Qué es un elemento Switch componible y cómo usarlo
  • Cómo agregar íconos iniciales a los campos de texto

Qué compilarás

  • Una app de Tip Time que calcula los importes de las propinas según el importe de la factura y el porcentaje de propina ingresados por el usuario

Requisitos

2. Obtén el código de inicio

Para comenzar, descarga el código de partida:

Descargar ZIP

Como alternativa, puedes clonar el repositorio de GitHub para el código:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-tip-calculator.git
$ cd basic-android-kotlin-compose-training-tip-calculator
$ git checkout state

Puedes explorar el código en el repositorio de GitHub de Tip Time.

3. Descripción general de la app de inicio

Este codelab comienza con la app de Tip Time del codelab anterior Introducción al estado en Compose, que proporciona la interfaz de usuario necesaria para calcular una propina con un porcentaje fijo de propina. El cuadro de texto Bill amount le permite al usuario ingresar el costo del servicio. La app calcula y muestra el importe de la propina en un elemento Text componible.

Ejecuta la app de Tip Time

  1. Abre el proyecto Tip Time en Android Studio y ejecuta la app en un emulador o dispositivo.
  2. Ingresa un importe de factura. La app calculará y mostrará el importe de la propina automáticamente.

b6bd5374911410ac.png

En la implementación actual, el porcentaje de la propina se codifica al 15%. En este codelab, extenderás esta función con un campo de texto que le permite a la app calcular un porcentaje de propina personalizado y redondear el importe.

Agrega los recursos de strings necesarios

  1. En la pestaña Project, haz clic en res > values > strings.xml.
  2. Entre las etiquetas <resources> del archivo strings.xml, agrega estos recursos de strings:
<string name="how_was_the_service">Tip Percentage</string>
<string name="round_up_tip">Round up tip?</string>

El archivo strings.xml debería verse como este fragmento de código, que incluye las cadenas del codelab anterior:

strings.xml

<resources>
    <string name="app_name">Tip Time</string>
    <string name="calculate_tip">Calculate Tip</string>
    <string name="bill_amount">Bill Amount</string>
    <string name="how_was_the_service">Tip Percentage</string>
    <string name="round_up_tip">Round up tip?</string>
    <string name="tip_amount">Tip Amount: %s</string>
</resources>

4. Agrega un campo de texto de porcentaje de propina

Un cliente podría querer una propina mayor o menor según la calidad del servicio proporcionado y otros motivos. Para adecuarse a esto, la app debe permitir que el usuario calcule una propina personalizada. En esta sección, agregarás un campo de texto para que el usuario ingrese un porcentaje de propina personalizado, como se muestra en esta imagen:

391b4b1a090687ef.png

Ya tienes un campo de texto componible para el Importe de facturación en tu app, que es la función de componibilidad sin estado EditNumberField(). En el codelab anterior, elevaste el estado amountInput del elemento EditNumberField() componible al elemento TipTimeLayout() componible, lo que hizo que el elemento EditNumberField() no tuviera estado.

Para agregar un campo de texto, puedes volver a usar el mismo elemento EditNumberField() componible, pero con una etiqueta diferente. Para realizar este cambio, debes pasar la etiqueta como parámetro, en lugar de codificarla dentro de la función de componibilidad EditNumberField().

Haz que la función de componibilidad EditNumberField() sea reutilizable:

  1. En el archivo MainActivity.kt, en los parámetros de la función de componibilidad EditNumberField(), agrega un recurso de strings label de tipo Int:
@Composable
fun EditNumberField(
    label: Int,
    value: String,
    onValueChanged: (String) -> Unit,
    modifier: Modifier = Modifier
)
  1. En el cuerpo de la función, reemplaza el ID de recurso de cadenas codificado con el parámetro label:
@Composable
fun EditNumberField(
    //...
) {
     TextField(
         //...
         label = { Text(stringResource(label)) },
         //...
     )
}
  1. Para indicar que se espera que el parámetro label sea una referencia de recursos de strings, anota el parámetro de la función con la anotación @StringRes:
@Composable
fun EditNumberField(
    @StringRes label: Int,
    value: String,
    onValueChanged: (String) -> Unit,
    modifier: Modifier = Modifier
)
  1. Importa lo siguiente:
import androidx.annotation.StringRes
  1. En la llamada a función EditNumberField() de la función de componibilidad TipTimeLayout(), establece el parámetro label en el recurso de cadenas R.string.bill_amount:
EditNumberField(
    label = R.string.bill_amount,
    value = amountInput,
    onValueChanged = { amountInput = it },
    modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
)
  1. En el panel Preview, no debería haber ningún cambio visual.

b223d5ba4a54f792.png

  1. En la función de componibilidad TipTimeLayout(), después de la llamada a función EditNumberField(), agrega otro campo de texto para el porcentaje de propina personalizado. Realiza una llamada a la función de componibilidad EditNumberField() con estos parámetros:
EditNumberField(
    label = R.string.how_was_the_service,
    value = "",
    onValueChanged = { },
    modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
)

Esto agrega otro cuadro de texto para el porcentaje de propina personalizado.

  1. La vista previa de la app ahora muestra un campo de texto Tip Percentage como se puede ver en esta imagen:

a5f5ef5e456e185e.png

  1. En la parte superior de la función de componibilidad TipTimeLayout(), agrega una propiedad var llamada tipInput para la variable de estado del campo de texto agregado. Usa mutableStateOf("") para inicializar la variable y rodea la llamada con la función remember:
var tipInput by remember { mutableStateOf("") }
  1. En la nueva llamada a función EditNumberField(), configura el parámetro con nombre value para la variable tipInput y, luego, actualiza la variable tipInput en la expresión lambda onValueChanged:
EditNumberField(
    label = R.string.how_was_the_service,
    value = tipInput,
    onValueChanged = { tipInput = it },
    modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
)
  1. En la función TipTimeLayout(), después de la definición de la variable tipInput. Define un elemento val llamado tipPercent que convierta la variable tipInput en un tipo Double. Usa un operador Elvis y muestra 0 si el valor es null. Este valor podría ser null si el campo de texto está vacío.
val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
  1. En la función TipTimeLayout(), actualiza la llamada a función calculateTip() y pasa la variable tipPercent como segundo parámetro:
val tip = calculateTip(amount, tipPercent)

El código para la función TipTimeLayout() debería verse de la siguiente manera:

@Composable
fun TipTimeLayout() {
    var amountInput by remember { mutableStateOf("") }
    var tipInput by remember { mutableStateOf("") }
    val amount = amountInput.toDoubleOrNull() ?: 0.0
    val tipPercent = tipInput.toDoubleOrNull() ?: 0.0

    val tip = calculateTip(amount, tipPercent)
    Column(
        modifier = Modifier.padding(40.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = stringResource(R.string.calculate_tip),
            modifier = Modifier
                .padding(bottom = 16.dp)
                .align(alignment = Alignment.Start)
        )
        EditNumberField(
            label = R.string.bill_amount,
            value = amountInput,
            onValueChanged = { amountInput = it },
            modifier = Modifier
                .padding(bottom = 32.dp)
                .fillMaxWidth()
        )
        EditNumberField(
            label = R.string.how_was_the_service,
            value = tipInput,
            onValueChanged = { tipInput = it },
            modifier = Modifier
                .padding(bottom = 32.dp)
                .fillMaxWidth()
        )
        Text(
            text = stringResource(R.string.tip_amount, tip),
            style = MaterialTheme.typography.displaySmall
        )
        Spacer(modifier = Modifier.height(150.dp))
    }
}
  1. Ejecuta la app en un emulador o dispositivo y, luego, ingresa un importe para la factura y el porcentaje de propina. ¿La app calcula el importe correcto para la propina?

captura de pantalla con el importe de la factura como 100, la propina como 20 por ciento y el importe de la propina se muestra como 20 dólares

5. Establece un botón de acción

En el codelab anterior, exploraste cómo usar la clase KeyboardOptions para establecer el tipo de teclado. En esta sección, aprenderás a configurar el botón de acción del teclado con el mismo KeyboardOptions. El botón de acción de teclado es el que se encuentra al final del teclado. Puedes ver algunos ejemplos en esta tabla:

Propiedad

Botón de acción del teclado

ImeAction.Search
Se usa cuando el usuario quiere ejecutar una búsqueda.

La imagen representa el ícono de búsqueda para ejecutar una búsqueda.

ImeAction.Send
Se usa cuando el usuario quiere enviar el texto del campo de entrada.

La imagen representa el ícono de Enviar para enviar el texto del campo de entrada.

ImeAction.Go
Se usa cuando el usuario quiere navegar al destino del texto de la entrada.

La imagen representa el ícono de Ir para navegar al destino del texto de la entrada.

En esta tarea, establecerás dos botones de acción diferentes para los cuadros de texto:

  • Un botón de acción Next para el cuadro de texto Bill Amount, que indica que el usuario ya completó la entrada actual y quiere pasar al siguiente cuadro de texto
  • Un botón de acción Done para el cuadro de texto Tip Percentage, que indica que el usuario terminó de proporcionar la entrada

Puedes ver ejemplos de teclados con estos botones de acción en las siguientes imágenes:

Agrega opciones del teclado:

  1. En la llamada a función TextField() de la función EditNumberField(), pasa el constructor KeyboardOptions, un argumento con nombre imeAction establecido en un valor ImeAction.Next. Usa la función KeyboardOptions.Default.copy() para asegurarte de usar las otras opciones predeterminadas.
import androidx.compose.ui.text.input.ImeAction

@Composable
fun EditNumberField(
    //...
) {
    TextField(
        //...
        keyboardOptions = KeyboardOptions.Default.copy(
            keyboardType = KeyboardType.Number,
            imeAction = ImeAction.Next
        )
    )
}
  1. Ejecuta la app en un emulador o dispositivo. El teclado ahora muestra el botón de acción Next (Siguiente), como se puede ver en esta imagen:

82574a95b658f052.png

Observa que el teclado muestra el mismo botón de acción Next (Siguiente) cuando se selecciona el campo de texto Tip Percentage (Porcentaje de propina). Sin embargo, te conviene usar dos botones de acción diferentes para los campos de texto. En breve, solucionarás este problema.

  1. Examina la función EditNumberField(). El parámetro keyboardOptions de la función TextField() está codificado. Si deseas crear botones de acción diferentes para los campos de texto, debes pasar el objeto KeyboardOptions como argumento, y lo harás en el paso siguiente.
// No need to copy, just examine the code.
fun EditNumberField(
    @StringRes label: Int,
    value: String,
    onValueChanged: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(
        //...
        keyboardOptions = KeyboardOptions.Default.copy(
           keyboardType = KeyboardType.Number,
           imeAction = ImeAction.Next
        )
    )
}
  1. En la definición de la función EditNumberField(), agrega un parámetro keyboardOptions de tipo KeyboardOptions. En el cuerpo de la función, asigna el parámetro con nombre keyboardOptions de la función TextField():
@Composable
fun EditNumberField(
    @StringRes label: Int,
    keyboardOptions: KeyboardOptions,
    // ...
){
    TextField(
        //...
        keyboardOptions = keyboardOptions
    )
}
  1. En la función TipTimeLayout(), actualiza la primera llamada a función EditNumberField() y pasa el parámetro con nombre keyboardOptions para el campo de texto Bill amount:
EditNumberField(
    label = R.string.bill_amount,
    keyboardOptions = KeyboardOptions.Default.copy(
        keyboardType = KeyboardType.Number,
        imeAction = ImeAction.Next
    ),
    // ...
)
  1. En la segunda llamada a función EditNumberField(), cambia el imeAction del campo de texto Tip Percentage a ImeAction.Done. Tu función debería verse como este fragmento de código:
EditNumberField(
    label = R.string.how_was_the_service,
    keyboardOptions = KeyboardOptions.Default.copy(
        keyboardType = KeyboardType.Number,
        imeAction = ImeAction.Done
    ),
    // ...
)
  1. Ejecuta la app. Se mostrarán los botones de acción Next y Done, como se puede ver en estas imágenes:

  1. Ingresa un importe de factura y haz clic en el botón de acción Next (Siguiente). Luego, ingresa un porcentaje de propina y haz clic en el botón de acción Done (Listo). Eso cerrará el teclado.

a9e3fbddfff829c8.gif

6. Agrega un interruptor

Un interruptor activa o desactiva el estado de un único elemento.

6923dfb1101602c7.png

Existen dos estados en un interruptor que permiten al usuario seleccionar entre dos opciones. Un interruptor consiste en una barra, un círculo y un ícono opcional, como se puede ver en estas imágenes:

b4f7f68b848bcc2b.png

El interruptor es un control de selección que se puede usar para ingresar decisiones o declarar preferencias, como los parámetros que se pueden ver en estas imágenes:

5cd8acb912ab38eb.png

El usuario puede arrastrar el círculo hacia adelante y hacia atrás para elegir la opción seleccionada, o simplemente presionar el botón de interruptor. Puedes ver otro ejemplo de un interruptor en este GIF, en el que la configuración de Visual options se activa a Modo oscuro:

eabf96ad496fd226.gif

Para obtener más información, consulta la documentación sobre interruptores.

Usas el elemento componible Switch para que el usuario pueda elegir si redondea la propina al número entero más cercano como se muestra en la siguiente imagen:

b42af9f2d3861e4.png

Agrega una fila para los elementos que Text y Switch componibles:

  1. Después de la función EditNumberField(), agrega una función de componibilidad RoundTheTipRow() y pasa un Modifier predeterminado, como argumentos similares a la función EditNumberField():
@Composable
fun RoundTheTipRow(modifier: Modifier = Modifier) {
}
  1. Implementa la función RoundTheTipRow(), agrega un elemento de diseño componible Row con el siguiente modifier para establecer el ancho de los elementos secundarios al máximo en la pantalla, centrar la alineación y garantizar un tamaño de 48dp:
Row(
   modifier = modifier
       .fillMaxWidth()
       .size(48.dp),
   verticalAlignment = Alignment.CenterVertically
) {
}
  1. Importa lo siguiente:
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
  1. En el bloque de lambda del elemento de componibilidad de diseño Row, agrega un elemento de componibilidad Text y usa el recurso de strings R.string.round_up_tip para mostrar una string Round up tip?:
Text(text = stringResource(R.string.round_up_tip))
  1. Después del elemento Text componible, agrega un elemento Switch también componible, pasa un parámetro con nombre checked establecido en roundUp y un parámetro con nombre onCheckedChange establecido en onRoundUpChanged.
Switch(
    checked = roundUp,
    onCheckedChange = onRoundUpChanged,
)

Esta tabla contiene información sobre estos parámetros, que son los mismos que definiste para la función RoundTheTipRow():

Parámetro

Descripción

checked

Indica si el interruptor está marcado. Este es el estado del elemento Switch componible.

onCheckedChange

Es la devolución de llamada a la que se llamará cuando se haga clic en el interruptor.

  1. Importa lo siguiente:
import androidx.compose.material3.Switch
  1. En la función RoundTheTipRow(), agrega un parámetro roundUp de tipo Boolean y una función lambda onRoundUpChanged que tome un Boolean, pero no muestre nada:
@Composable
fun RoundTheTipRow(
    roundUp: Boolean,
    onRoundUpChanged: (Boolean) -> Unit,
    modifier: Modifier = Modifier
)

Con esta acción, se eleva el estado del interruptor.

  1. En el elemento de componibilidad Switch, agrega este modifier para alinear el elemento de componibilidad Switch al final de la pantalla:
       Switch(
           modifier = modifier
               .fillMaxWidth()
               .wrapContentWidth(Alignment.End),
           //...
       )
  1. Importa lo siguiente:
import androidx.compose.foundation.layout.wrapContentWidth
  1. En la función TipTimeLayout(), agrega una variable var para el estado del elemento componible Switch. Crea una variable var llamada roundUp y establécela en mutableStateOf(), con false como valor inicial. Rodea la llamada con remember { }.
fun TipTimeLayout() {
    //...
    var roundUp by remember { mutableStateOf(false) }

    //...
    Column(
        ...
    ) {
      //...
   }
}

Esta es la variable del estado componible Switch y el valor falso será el predeterminado.

  1. En el bloque Column de la función TipTimeLayout(), después del campo de texto Tip Percentage, llama a la función RoundTheTipRow() con los siguientes argumentos: un parámetro con nombre roundUp establecido en roundUp y un parámetro con nombre onRoundUpChanged establecido en una devolución de llamada lambda que actualice el valor roundUp:
@Composable
fun TipTimeLayout() {
    //...

    Column(
        ...
    ) {
        Text(
            ...
        )
        Spacer(...)
        EditNumberField(
            ...
        )
        EditNumberField(
            ...
        )
        RoundTheTipRow(
             roundUp = roundUp,
             onRoundUpChanged = { roundUp = it },
             modifier = Modifier.padding(bottom = 32.dp)
         )
        Text(
            ...
        )
    }
}

Se mostrará la fila Round up tip?.

  1. Ejecuta la app. Esta mostrará el botón de activación Round up tip? (¿Quieres redondear la propina?).

5225395a29022a5e.png

  1. Ingresa un importe de la factura y un porcentaje de propina y selecciona el botón de activación Round up tip? (¿Quieres redondear la propina?). El importe de la propina no se redondea porque todavía necesitas actualizar la función calculateTip(); lo haremos en la siguiente sección.

Actualiza la función calculateTip() para redondear la propina.

Modifica la función calculateTip() de modo que acepte una variable Boolean para redondear la propina al número entero más cercano:

  1. Para redondear la propina, la función calculateTip() debe conocer el estado del interruptor, que es un Boolean. En la función calculateTip(), agrega un parámetro roundUp de tipo Boolean:
private fun calculateTip(
    amount: Double,
    tipPercent: Double = 15.0,
    roundUp: Boolean
): String {
    //...
}
  1. En la función calculateTip(), antes de la sentencia return, agrega una condición if() que verifique el valor roundUp. Si roundUp es true, define una variable tip, establécela en kotlin.math.ceil() y pasa la función tip como argumento:
if (roundUp) {
    tip = kotlin.math.ceil(tip)
}

La función calculateTip() completa debería verse como este fragmento de código:

private fun calculateTip(amount: Double, tipPercent: Double = 15.0, roundUp: Boolean): String {
    var tip = tipPercent / 100 * amount
    if (roundUp) {
        tip = kotlin.math.ceil(tip)
    }
    return NumberFormat.getCurrencyInstance().format(tip)
}
  1. En la función TipTimeLayout(), actualiza la llamada a la función calculateTip() y pasa un parámetro roundUp:
val tip = calculateTip(amount, tipPercent, roundUp)
  1. Ejecuta la app. Ahora, esta redondea el importe de la propina, como puedes ver en las siguientes imágenes:

7. Agrega compatibilidad con la orientación horizontal

Los dispositivos Android están disponibles en una variedad de factores de forma, como teléfonos, tablets, plegables y dispositivos ChromeOS, que incluyen una gran gama de tamaños de pantalla. La app debe admitir tanto la orientación vertical como la horizontal.

  1. Prueba la app en modo horizontal y activa el Giro automático.

8566fc367d5a5b2f.png

  1. Rota el emulador o dispositivo hacia la izquierda. Observa que no puedes ver el importe de la propina. Para resolver este problema, necesitarás una barra de desplazamiento vertical que te ayude a desplazarte por la pantalla de la app.

28d23a73c2a5ea24.png

  1. Agrega .verticalScroll(rememberScrollState()) al modificador para permitir que la columna se desplace verticalmente. La función rememberScrollState() crea y recuerda automáticamente el estado de desplazamiento.
@Composable
fun TipTimeLayout() {
    // ...
    Column(
        modifier = Modifier
            .padding(40.dp)
            .verticalScroll(rememberScrollState()),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        //...
    }
}
  1. Importa lo siguiente:
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
  1. Vuelve a ejecutar la app. Intenta desplazarte en el modo horizontal.

179866a0fae00401.gif

8. Agrega un ícono inicial a los campos de texto (opcional)

Los íconos pueden hacer que el campo de texto resulte más atractivo visualmente y proporcionar información adicional sobre él. Los íconos se pueden usar para transmitir información sobre el propósito del campo de texto, como qué tipo de datos se esperan o qué tipo de entrada es necesaria. Por ejemplo, el ícono de un teléfono junto a un campo de texto podría indicar que se espera que el usuario ingrese un número de teléfono.

Los íconos se pueden usar para guiar la entrada del usuario proporcionando indicios visuales sobre lo que se espera. Por ejemplo, el ícono de un calendario junto a un campo de texto podría indicar que se espera que el usuario ingrese una fecha.

A continuación, se muestra un ejemplo de un campo de texto con un ícono de búsqueda que indica que se debe ingresar el término de búsqueda.

9318c9a2414c4add.png

Agrega otro parámetro al elemento componible EditNumberField() llamado leadingIcon del tipo Int. Anótalo con @DrawableRes.

@Composable
fun EditNumberField(
    @StringRes label: Int,
    @DrawableRes leadingIcon: Int,
    keyboardOptions: KeyboardOptions,
    value: String,
    onValueChanged: (String) -> Unit,
    modifier: Modifier = Modifier
)
  1. Importa lo siguiente:
import androidx.annotation.DrawableRes
import androidx.compose.material3.Icon
  1. Agrega el ícono inicial al campo de texto. leadingIcon toma un elemento componible, por lo que pasarás el siguiente elemento Icon de este tipo.
TextField(
    value = value,
    leadingIcon = { Icon(painter = painterResource(id = leadingIcon), null) },
    //...
)
  1. Pasa el ícono inicial a los campos de texto. Para tu conveniencia, los íconos ya están presentes en el código de partida.
EditNumberField(
    label = R.string.bill_amount,
    leadingIcon = R.drawable.money,
    // Other arguments
)
EditNumberField(
    label = R.string.how_was_the_service,
    leadingIcon = R.drawable.percent,
    // Other arguments
)
  1. Ejecuta la app.

bff007b9d67ede83.png

¡Felicitaciones! Ahora tu app puede calcular propinas personalizadas.

9. Obtén el código de la solución

Para descargar el código del codelab terminado, puedes usar este comando de git:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-tip-calculator.git

También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.

Descargar ZIP

Si deseas ver el código de la solución, puedes hacerlo en GitHub.

10. Conclusión

¡Felicitaciones! Agregaste la funcionalidad de propina personalizada a tu app de Tip Time. Ahora, la app permite que los usuarios ingresen un porcentaje de propina personalizado y redondeen el importe correspondiente. Comparte tu trabajo en redes sociales con el hashtag #AndroidBasics.

Más información