Add basic date and time pickers (#542)

This commit is contained in:
David Perez 2024-01-08 21:33:43 -06:00 committed by Álison Fernandes
parent cea26f5e32
commit 14d686af76
9 changed files with 406 additions and 9 deletions

View file

@ -0,0 +1,129 @@
package com.x8bit.bitwarden.ui.platform.components.dialog
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.time.ZonedDateTime
/**
* A custom composable representing a button that can display the date picker dialog.
*
* This composable displays an [OutlinedTextField] with a dropdown icon as a trailing icon.
* When the field is clicked, a date picker dialog appears.
*
* @param currentZonedDateTime The currently displayed time.
* @param formatPattern The pattern to format the displayed time.
* @param onDateSelect The callback to be invoked when a new date is selected.
* @param modifier A [Modifier] that you can use to apply custom modifications to the composable.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitwardenDateSelectButton(
currentZonedDateTime: ZonedDateTime,
formatPattern: String,
onDateSelect: (millis: Long) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowDialog: Boolean by rememberSaveable { mutableStateOf(false) }
val formattedDate by remember(currentZonedDateTime) {
mutableStateOf(currentZonedDateTime.toFormattedPattern(formatPattern))
}
// TODO: This should be "Date" but we don't have that string (BIT-1405)
val label = stringResource(id = R.string.deletion_date)
OutlinedTextField(
modifier = modifier
.clearAndSetSemantics {
role = Role.DropdownList
contentDescription = "$label, $formattedDate"
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = { shouldShowDialog = !shouldShowDialog },
),
textStyle = MaterialTheme.typography.bodyLarge,
readOnly = true,
label = { Text(text = label) },
value = formattedDate,
onValueChange = { },
enabled = shouldShowDialog,
trailingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_region_select_dropdown),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledSupportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
if (shouldShowDialog) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = currentZonedDateTime.toInstant().toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = { shouldShowDialog = false },
confirmButton = {
TextButton(
onClick = {
onDateSelect(requireNotNull(datePickerState.selectedDateMillis))
shouldShowDialog = false
},
modifier = modifier,
) {
Text(
text = stringResource(id = R.string.ok),
style = MaterialTheme.typography.labelLarge,
)
}
},
dismissButton = {
TextButton(
onClick = { shouldShowDialog = false },
modifier = modifier,
) {
Text(
text = stringResource(id = R.string.cancel),
style = MaterialTheme.typography.labelLarge,
)
}
},
) {
DatePicker(state = datePickerState)
}
}
}

View file

@ -0,0 +1,158 @@
package com.x8bit.bitwarden.ui.platform.components.dialog
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimeInput
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.x8bit.bitwarden.R
/**
* A custom composable representing a dialog that displays the time picker dialog.
*
* @param initialHour The initial hour to display.
* @param initialMinute The initial minute to display.
* @param onTimeSelect The callback to be invoked when a new time is selected.
* @param onDismissRequest The callback to be invoked when a time has been selected.
* @param is24Hour Indicates if the time selector should use a 24 hour format or a 12 hour format
* with AM/PM.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitwardenTimePickerDialog(
initialHour: Int,
initialMinute: Int,
onTimeSelect: (hour: Int, minute: Int) -> Unit,
onDismissRequest: () -> Unit,
is24Hour: Boolean,
) {
var showTimeInput by remember { mutableStateOf(false) }
val timePickerState = rememberTimePickerState(
initialHour = initialHour,
initialMinute = initialMinute,
is24Hour = is24Hour,
)
TimePickerDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = { onTimeSelect(timePickerState.hour, timePickerState.minute) },
) {
Text(
text = stringResource(id = R.string.ok),
style = MaterialTheme.typography.labelLarge,
)
}
},
dismissButton = {
TextButton(
onClick = onDismissRequest,
) {
Text(
text = stringResource(id = R.string.cancel),
style = MaterialTheme.typography.labelLarge,
)
}
},
inputToggleButton = {
IconButton(
modifier = Modifier.size(48.dp),
onClick = { showTimeInput = !showTimeInput },
) {
@Suppress("MaxLineLength")
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_keyboard),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
contentDescription = stringResource(
// TODO: Get our own string for this (BIT-1405)
id = androidx.compose.material3.R.string.m3c_date_picker_switch_to_input_mode,
),
)
}
},
) {
if (showTimeInput) {
TimeInput(state = timePickerState)
} else {
TimePicker(state = timePickerState)
}
}
}
@Composable
private fun TimePickerDialog(
onDismissRequest: () -> Unit,
inputToggleButton: @Composable () -> Unit,
dismissButton: @Composable () -> Unit,
confirmButton: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp,
modifier = Modifier
.width(IntrinsicSize.Min)
.height(IntrinsicSize.Min)
.background(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp),
// TODO: This should be "Select time" but we don't have that string (BIT-1405)
text = stringResource(id = R.string.time),
style = MaterialTheme.typography.labelMedium,
)
content()
Row(modifier = Modifier.fillMaxWidth()) {
inputToggleButton()
Spacer(modifier = Modifier.weight(1f))
dismissButton()
Spacer(modifier = Modifier.width(8.dp))
confirmButton()
}
}
}
}
}

View file

@ -0,0 +1,100 @@
package com.x8bit.bitwarden.ui.platform.components.dialog
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.time.ZonedDateTime
/**
* A custom composable representing a button that can display the time picker dialog.
*
* This composable displays an [OutlinedTextField] with a dropdown icon as a trailing icon.
* When the field is clicked, a time picker dialog appears.
*
* @param currentZonedDateTime The currently displayed time.
* @param formatPattern The pattern to format the displayed time.
* @param onTimeSelect The callback to be invoked when a new time is selected.
* @param modifier A [Modifier] that you can use to apply custom modifications to the composable.
* @param is24Hour Indicates if the time selector should use a 24 hour format or a 12 hour format
* with AM/PM.
*/
@Composable
fun BitwardenTimeSelectButton(
currentZonedDateTime: ZonedDateTime,
formatPattern: String,
onTimeSelect: (hour: Int, minute: Int) -> Unit,
modifier: Modifier = Modifier,
is24Hour: Boolean = false,
) {
var shouldShowDialog: Boolean by rememberSaveable { mutableStateOf(false) }
val formattedTime by remember(currentZonedDateTime) {
mutableStateOf(currentZonedDateTime.toFormattedPattern(formatPattern))
}
val label = stringResource(id = R.string.time)
OutlinedTextField(
modifier = modifier
.clearAndSetSemantics {
role = Role.DropdownList
contentDescription = "$label, $formattedTime"
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = { shouldShowDialog = !shouldShowDialog },
),
textStyle = MaterialTheme.typography.bodyLarge,
readOnly = true,
label = { Text(text = label) },
value = formattedTime,
onValueChange = { },
enabled = shouldShowDialog,
trailingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_region_select_dropdown),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledSupportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
if (shouldShowDialog) {
BitwardenTimePickerDialog(
initialHour = currentZonedDateTime.hour,
initialMinute = currentZonedDateTime.minute,
onTimeSelect = { hour, minute ->
shouldShowDialog = false
onTimeSelect(hour, minute)
},
onDismissRequest = { shouldShowDialog = false },
is24Hour = is24Hour,
)
}
}

View file

@ -1,14 +1,14 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.util
package com.x8bit.bitwarden.ui.platform.util
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAccessor
import java.util.TimeZone
/**
* Converts the [Instant] to a formatted string based on the provided pattern and time zone.
* Converts the [TemporalAccessor] to a formatted string based on the provided pattern and timezone.
*/
fun Instant.toFormattedPattern(
fun TemporalAccessor.toFormattedPattern(
pattern: String,
zone: ZoneId = TimeZone.getDefault().toZoneId(),
): String {

View file

@ -10,8 +10,8 @@ import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map

View file

@ -3,7 +3,7 @@ package com.x8bit.bitwarden.ui.tools.feature.send.util
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.tools.feature.send.SendState
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendStatusIcon
import java.time.Instant

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24">
<path
android:fillColor="#45464F"
android:fillType="evenOdd"
android:pathData="M20,5H4C2.9,5 2.01,5.9 2.01,7L2,17C2,18.1 2.9,19 4,19H20C21.1,19 22,18.1 22,17V7C22,5.9 21.1,5 20,5ZM20,7V17H4V7H20ZM13,8H11V10H13V8ZM11,11H13V13H11V11ZM10,8H8V10H10V8ZM8,11H10V13H8V11ZM7,11H5V13H7V11ZM5,8H7V10H5V8ZM16,14H8V16H16V14ZM14,11H16V13H14V11ZM16,8H14V10H16V8ZM17,11H19V13H17V11ZM19,8H17V10H19V8Z" />
</vector>

View file

@ -1,11 +1,11 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.util
package com.x8bit.bitwarden.ui.platform.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZoneId
class InstantExtensionsTest {
class TemporalAccessorExtensionsTest {
@Test
fun `toFormattedPattern should return correctly formatted string`() {

View file

@ -8,7 +8,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import io.mockk.every
import io.mockk.just
import io.mockk.mockk