mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
Add basic date and time pickers (#542)
This commit is contained in:
parent
cea26f5e32
commit
14d686af76
9 changed files with 406 additions and 9 deletions
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 {
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
10
app/src/main/res/drawable/ic_keyboard.xml
Normal file
10
app/src/main/res/drawable/ic_keyboard.xml
Normal 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>
|
|
@ -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`() {
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue