diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenDateSelectButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenDateSelectButton.kt new file mode 100644 index 0000000000..42e350e4e9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenDateSelectButton.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimePickerDialog.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimePickerDialog.kt new file mode 100644 index 0000000000..0067030855 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimePickerDialog.kt @@ -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() + } + } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimeSelectButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimeSelectButton.kt new file mode 100644 index 0000000000..88d9b4f23b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimeSelectButton.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/TemporalAccessorExtensions.kt similarity index 56% rename from app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensions.kt rename to app/src/main/java/com/x8bit/bitwarden/ui/platform/util/TemporalAccessorExtensions.kt index da4580c557..867812d090 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/TemporalAccessorExtensions.kt @@ -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 { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt index 3b6293f232..4f68cf87ad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensions.kt index f64c4928d2..2a3f016f51 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensions.kt @@ -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 diff --git a/app/src/main/res/drawable/ic_keyboard.xml b/app/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 0000000000..d5d99b5602 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/TemporalAccessorExtensionsTest.kt similarity index 86% rename from app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensionsTest.kt rename to app/src/test/java/com/x8bit/bitwarden/ui/platform/util/TemporalAccessorExtensionsTest.kt index e1c4f6cabd..c0c9196810 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/TemporalAccessorExtensionsTest.kt @@ -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`() { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt index 51fe12d76a..9cb8902921 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt @@ -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