PM-22502: Format dates and times correctly for locale (#5333)

This commit is contained in:
David Perez 2025-06-09 13:30:30 -05:00 committed by GitHub
parent d822be62e1
commit 9cdfe0c5d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 445 additions and 115 deletions

View File

@ -4,7 +4,7 @@ package com.x8bit.bitwarden.ui.platform.feature.search.util
import androidx.annotation.DrawableRes
import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparator
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.ui.platform.base.util.removeDiacritics
@ -27,8 +27,7 @@ import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons
import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
import java.time.Clock
private const val DELETION_DATE_PATTERN: String = "MMM d, uuuu, hh:mm a"
import java.time.format.FormatStyle
/**
* Updates a [SearchTypeData] with the given data if necessary.
@ -353,7 +352,11 @@ private fun SendView.toDisplayItem(
id = id.orEmpty(),
title = name,
titleTestTag = "SendNameLabel",
subtitle = deletionDate.toFormattedPattern(DELETION_DATE_PATTERN, clock),
subtitle = deletionDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
subtitleTestTag = "SendDateLabel",
iconData = IconData.Local(
iconRes = when (type) {

View File

@ -1,12 +1,14 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.about.util
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.core.data.util.toFormattedTimeStyle
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import java.time.Clock
import java.time.Instant
import java.time.format.FormatStyle
/**
* Creates a properly formatted string indicating when the logging will stop for the active log or
@ -23,7 +25,13 @@ private fun FlightRecorderDataSet.FlightRecorderData.getStopsLoggingString(
clock: Clock,
): Text {
val completionInstant = Instant.ofEpochMilli(this.startTimeMs + this.durationMs)
val completionDate = completionInstant.toFormattedPattern(pattern = "M/d/yy", clock = clock)
val completionTime = completionInstant.toFormattedPattern(pattern = "h:mm a", clock = clock)
val completionDate = completionInstant.toFormattedDateStyle(
dateStyle = FormatStyle.SHORT,
clock = clock,
)
val completionTime = completionInstant.toFormattedTimeStyle(
timeStyle = FormatStyle.SHORT,
clock = clock,
)
return R.string.stops_logging_on.asText(completionDate, completionTime)
}

View File

@ -572,13 +572,11 @@ private fun SessionCustomTimeoutRow(
cardStyle = CardStyle.Middle(),
modifier = modifier,
) {
val formattedTime = LocalTime
.ofSecondOfDay(
vaultTimeoutInMinutes * MINUTES_PER_HOUR.toLong(),
)
.toFormattedPattern("HH:mm")
Text(
text = formattedTime,
text = LocalTime
.ofSecondOfDay(vaultTimeoutInMinutes * MINUTES_PER_HOUR.toLong())
.toFormattedPattern(pattern = "HH:mm"),
style = BitwardenTheme.typography.labelSmall,
color = BitwardenTheme.colorScheme.text.primary,
)

View File

@ -5,7 +5,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginap
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.format.FormatStyle
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -214,8 +215,9 @@ class LoginApprovalViewModel @Inject constructor(
email = email,
fingerprint = result.authRequest.fingerprint,
ipAddress = result.authRequest.ipAddress,
time = result.authRequest.creationDate.toFormattedPattern(
pattern = "M/d/yy hh:mm a",
time = result.authRequest.creationDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.SHORT,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
),

View File

@ -3,7 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pending
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.util.Text
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.format.FormatStyle
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -144,8 +145,9 @@ class PendingRequestsViewModel @Inject constructor(
PendingRequestsState.ViewState.Content.PendingLoginRequest(
fingerprintPhrase = request.fingerprint,
platform = request.platform,
timestamp = request.creationDate.toFormattedPattern(
pattern = "M/d/yy hh:mm a",
timestamp = request.creationDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.SHORT,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
)

View File

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.util
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedTimeStyle
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
@ -11,6 +13,7 @@ import com.x8bit.bitwarden.ui.platform.util.formatBytes
import kotlinx.collections.immutable.toImmutableList
import java.time.Clock
import java.time.Instant
import java.time.format.FormatStyle
import java.time.temporal.ChronoUnit
/**
@ -67,14 +70,20 @@ private fun FlightRecorderDataSet.FlightRecorderData.expiresIn(clock: Clock): Te
R.string.expired.asText()
} else if (now.isAfter(expirationTime.minus(1, ChronoUnit.DAYS))) {
// We are within 24 hours of expiration, so show the specific time.
val expirationTime = expirationTime.toFormattedPattern(pattern = "h:mm a", clock = clock)
val expirationTime = expirationTime.toFormattedTimeStyle(
timeStyle = FormatStyle.SHORT,
clock = clock,
)
R.string.expires_at.asText(expirationTime)
} else if (dayBeforeExpiration.dayOfYear == now.atZone(clock.zone).dayOfYear) {
// We expire tomorrow based on the day of year.
R.string.expires_tomorrow.asText()
} else {
// Let them know the date it expires.
val expirationDate = expirationTime.toFormattedPattern(pattern = "M/d/yy", clock = clock)
val expirationDate = expirationTime.toFormattedDateStyle(
dateStyle = FormatStyle.SHORT,
clock = clock,
)
R.string.expires_on.asText(expirationDate)
}
}

View File

@ -3,7 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
@ -21,12 +21,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.Instant
import java.time.format.FormatStyle
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val VAULT_LAST_SYNC_TIME_PATTERN: String = "M/d/yyyy h:mm a"
/**
* View model for the other screen.
*/
@ -46,7 +45,11 @@ class OtherViewModel @Inject constructor(
clearClipboardFrequency = settingsRepo.clearClipboardFrequency,
lastSyncTime = settingsRepo
.vaultLastSync
?.toFormattedPattern(VAULT_LAST_SYNC_TIME_PATTERN, clock)
?.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.SHORT,
clock = clock,
)
.orEmpty(),
dialogState = null,
),
@ -137,7 +140,11 @@ class OtherViewModel @Inject constructor(
it.copy(
lastSyncTime = action
.vaultLastSyncTime
?.toFormattedPattern(VAULT_LAST_SYNC_TIME_PATTERN, clock)
?.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.SHORT,
clock = clock,
)
.orEmpty(),
dialogState = null,
)

View File

@ -4,7 +4,7 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.format.FormatStyle
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -141,8 +142,9 @@ class PasswordHistoryViewModel @Inject constructor(
val passwords = this?.map { passwordHistoryView ->
GeneratedPassword(
password = passwordHistoryView.password,
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
pattern = "MM/dd/yy h:mm a",
date = passwordHistoryView.lastUsedDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.SHORT,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
)

View File

@ -10,7 +10,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
@ -22,6 +22,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.ZonedDateTime
import java.time.format.FormatStyle
import java.time.temporal.ChronoUnit
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
@ -94,7 +95,13 @@ private sealed class CustomDeletionOption : Parcelable {
override fun getText(
clock: Clock,
): Text = time.toFormattedPattern(pattern = "d MMM, yyyy, h:mma", clock = clock).asText()
): Text = time
.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.SHORT,
clock = clock,
)
.asText()
}
@Parcelize

View File

@ -1,13 +1,12 @@
package com.x8bit.bitwarden.ui.tools.feature.send.util
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.tools.feature.send.SendState
import java.time.Clock
private const val DELETION_DATE_PATTERN: String = "MMM d, uuuu, hh:mm a"
import java.time.format.FormatStyle
/**
* Transforms [SendData] into [SendState.ViewState].
@ -34,8 +33,9 @@ private fun List<SendView>.toSendContent(
SendState.ViewState.Content.SendItem(
id = requireNotNull(sendView.id),
name = sendView.name,
deletionDate = sendView.deletionDate.toFormattedPattern(
pattern = DELETION_DATE_PATTERN,
deletionDate = sendView.deletionDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
type = when (sendView.type) {

View File

@ -1,6 +1,6 @@
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.send.SendFileView
import com.bitwarden.send.SendTextView
import com.bitwarden.send.SendType
@ -8,6 +8,7 @@ import com.bitwarden.send.SendView
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.ViewSendState
import java.time.Clock
import java.time.format.FormatStyle
/**
* Transforms the given [SendView] to a [ViewSendState.ViewState.Content].
@ -23,9 +24,11 @@ fun SendView.toViewSendViewStateContent(
},
shareLink = this.toSendUrl(baseWebSendUrl = baseWebSendUrl),
sendName = this.name,
deletionDate = this
.deletionDate
.toFormattedPattern(pattern = "d MMM, yyyy, h:mma", clock = clock),
deletionDate = this.deletionDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
maxAccessCount = this.maxAccessCount?.toInt(),
currentAccessCount = this.accessCount.toInt(),
notes = this.notes,

View File

@ -2,7 +2,8 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.core.data.util.toFormattedTimeStyle
import com.bitwarden.ui.util.asText
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
@ -27,11 +28,9 @@ import com.x8bit.bitwarden.ui.vault.model.VaultIdentityTitle
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType.Companion.fromId
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
import java.time.Clock
import java.time.format.FormatStyle
import java.util.UUID
private const val PASSKEY_CREATION_DATE_PATTERN: String = "M/d/yy"
private const val PASSKEY_CREATION_TIME_PATTERN: String = "hh:mm a"
/**
* Transforms [CipherView] into [VaultAddEditState.ViewState].
*/
@ -332,6 +331,6 @@ private fun List<Fido2Credential>?.getPrimaryFido2CredentialOrNull(
* "M/d/yy, hh:mm a".
*/
private fun Fido2Credential.getCreationDateTime(clock: Clock) = R.string.created_xy.asText(
creationDate.toFormattedPattern(pattern = PASSKEY_CREATION_DATE_PATTERN, clock = clock),
creationDate.toFormattedPattern(pattern = PASSKEY_CREATION_TIME_PATTERN, clock = clock),
creationDate.toFormattedDateStyle(dateStyle = FormatStyle.SHORT, clock = clock),
creationDate.toFormattedTimeStyle(timeStyle = FormatStyle.SHORT, clock = clock),
)

View File

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.ui.vault.feature.item.util
import androidx.annotation.DrawableRes
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.core.data.util.toFormattedTimeStyle
import com.bitwarden.ui.platform.base.util.nullIfAllEqual
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.bitwarden.ui.platform.base.util.orZeroWidthSpace
@ -27,12 +29,9 @@ import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
import kotlinx.collections.immutable.ImmutableList
import java.time.Clock
import java.time.format.FormatStyle
import java.util.Locale
private const val LAST_UPDATED_DATE_TIME_PATTERN: String = "M/d/yy hh:mm a"
private const val FIDO2_CREDENTIAL_CREATION_DATE_PATTERN: String = "M/d/yy"
private const val FIDO2_CREDENTIAL_CREATION_TIME_PATTERN: String = "h:mm a"
/**
* Transforms [VaultData] into [VaultItemState.ViewState].
*/
@ -62,8 +61,9 @@ fun CipherView.toViewState(
?.find { it.id == fieldView.hashCode().toString() },
)
},
lastUpdated = revisionDate.toFormattedPattern(
pattern = LAST_UPDATED_DATE_TIME_PATTERN,
lastUpdated = revisionDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.SHORT,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
notes = notes,
@ -124,8 +124,9 @@ fun CipherView.toViewState(
uris = loginValues.uris.orEmpty().map { it.toUriData() },
passwordRevisionDate = loginValues
.passwordRevisionDate
?.toFormattedPattern(
pattern = LAST_UPDATED_DATE_TIME_PATTERN,
?.toFormattedDateTimeStyle(
dateStyle = FormatStyle.SHORT,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
isPremiumUser = isPremiumUser,
@ -248,12 +249,12 @@ private fun LoginUriView.toUriData() =
private fun Fido2Credential?.getCreationDateText(clock: Clock): Text? =
this?.let {
R.string.created_xy.asText(
creationDate.toFormattedPattern(
pattern = FIDO2_CREDENTIAL_CREATION_DATE_PATTERN,
creationDate.toFormattedDateStyle(
dateStyle = FormatStyle.SHORT,
clock = clock,
),
creationDate.toFormattedPattern(
pattern = FIDO2_CREDENTIAL_CREATION_TIME_PATTERN,
creationDate.toFormattedTimeStyle(
timeStyle = FormatStyle.SHORT,
clock = clock,
),
)

View File

@ -3,7 +3,7 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
import androidx.annotation.DrawableRes
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
@ -35,8 +35,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
import com.x8bit.bitwarden.ui.vault.model.TotpData
import java.time.Clock
private const val DELETION_DATE_PATTERN: String = "MMM d, uuuu, hh:mm a"
import java.time.format.FormatStyle
/**
* Determines a predicate to filter a list of [CipherView] based on the
@ -461,7 +460,11 @@ private fun SendView.toDisplayItem(
titleTestTag = "SendNameLabel",
secondSubtitle = null,
secondSubtitleTestTag = null,
subtitle = deletionDate.toFormattedPattern(DELETION_DATE_PATTERN, clock),
subtitle = deletionDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.SHORT,
clock = clock,
),
subtitleTestTag = "SendDateLabel",
iconData = IconData.Local(
iconRes = when (type) {

View File

@ -1,12 +1,14 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.core.data.util.toFormattedTimeStyle
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import java.time.Clock
import java.time.Instant
import java.time.format.FormatStyle
/**
* Helper function to create a [BitwardenSnackbarData] representing the active flight recorder.
@ -21,8 +23,8 @@ fun FlightRecorderDataSet.toSnackbarData(
?: return null
return BitwardenSnackbarData(
message = R.string.flight_recorder_banner_message.asText(
expirationTime.toFormattedPattern(pattern = "M/d/yy", clock = clock),
expirationTime.toFormattedPattern(pattern = "h:mm a", clock = clock),
expirationTime.toFormattedDateStyle(dateStyle = FormatStyle.SHORT, clock = clock),
expirationTime.toFormattedTimeStyle(timeStyle = FormatStyle.SHORT, clock = clock),
),
messageHeader = R.string.flight_recorder_banner_title.asText(),
actionLabel = R.string.go_to_settings.asText(),

View File

@ -183,7 +183,7 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
email = EMAIL,
fingerprint = AUTH_REQUEST.fingerprint,
ipAddress = AUTH_REQUEST.ipAddress,
time = "9/13/24 12:00 AM",
time = "9/13/24, 12:00 AM",
),
)
val viewModel = createViewModel()
@ -455,7 +455,7 @@ private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
email = EMAIL,
fingerprint = FINGERPRINT,
ipAddress = "1.0.0.1",
time = "9/13/24 12:00 AM",
time = "9/13/24, 12:00 AM",
),
)
private const val USER_ID = "userID"

View File

@ -56,7 +56,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
@Test
fun `getPendingResults success with content should update state with some requests filtered`() {
val dateTimeFormatter = DateTimeFormatter
.ofPattern("M/d/yy hh:mm a")
.ofPattern("M/d/yy, hh:mm a")
.withZone(fixedClock.zone)
val nowZonedDateTime = ZonedDateTime.now(fixedClock)
val requestList = listOf(
@ -284,15 +284,11 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
}
}
@Suppress("LongMethod")
@Test
fun `on LifecycleResume should update state`() = runTest {
val dateTimeFormatter = DateTimeFormatter
.ofPattern("M/d/yy hh:mm a")
.withZone(fixedClock.zone)
val nowZonedDateTime = ZonedDateTime.now()
val fiveMinZonedDateTime = ZonedDateTime.now().minusMinutes(5)
val sixMinZonedDateTime = ZonedDateTime.now().minusMinutes(6)
val nowZonedDateTime = ZonedDateTime.now(fixedClock)
val fiveMinZonedDateTime = ZonedDateTime.now(fixedClock).minusMinutes(5)
val sixMinZonedDateTime = ZonedDateTime.now(fixedClock).minusMinutes(6)
val requestList = listOf(
AuthRequest(
id = "1",
@ -353,12 +349,12 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
PendingRequestsState.ViewState.Content.PendingLoginRequest(
fingerprintPhrase = "pantry-overdue-survive-sleep-jab",
platform = "Android",
timestamp = nowZonedDateTime.format(dateTimeFormatter),
timestamp = "10/27/23, 12:00 PM",
),
PendingRequestsState.ViewState.Content.PendingLoginRequest(
fingerprintPhrase = "erupt-anew-matchbook-disk-student",
platform = "iOS",
timestamp = fiveMinZonedDateTime.format(dateTimeFormatter),
timestamp = "10/27/23, 11:55 AM",
),
),
),

View File

@ -157,7 +157,7 @@ class OtherViewModelTest : BaseViewModelTest() {
mutableVaultLastSyncStateFlow.tryEmit(newSyncTime)
assertEquals(
DEFAULT_STATE.copy(
lastSyncTime = "10/27/2023 12:00 PM",
lastSyncTime = "Oct 27, 2023, 12:00 PM",
dialogState = null,
),
awaitItem(),
@ -236,6 +236,6 @@ private val DEFAULT_STATE = OtherState(
allowScreenCapture = false,
allowSyncOnRefresh = false,
clearClipboardFrequency = ClearClipboardFrequency.NEVER,
lastSyncTime = "10/26/2023 12:00 PM",
lastSyncTime = "Oct 26, 2023, 12:00 PM",
dialogState = null,
)

View File

@ -3,7 +3,7 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
@ -29,6 +29,7 @@ import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.FormatStyle
class PasswordHistoryViewModelTest : BaseViewModelTest() {
@ -171,7 +172,7 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
passwords = listOf(
PasswordHistoryState.GeneratedPassword(
password = "mockPassword-1",
date = "10/27/23 12:00 PM",
date = "10/27/23, 12:00 PM",
),
),
),
@ -194,8 +195,9 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
passwords = listOf(
PasswordHistoryState.GeneratedPassword(
password = "password",
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
pattern = "MM/dd/yy h:mm a",
date = passwordHistoryView.lastUsedDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.SHORT,
timeStyle = FormatStyle.SHORT,
clock = fixedClock,
),
),

View File

@ -48,7 +48,7 @@ class SendViewExtensionsTest {
),
shareLink = sendUrl,
sendName = "mockName-1",
deletionDate = "27 Oct, 2023, 12:00PM",
deletionDate = "Oct 27, 2023, 12:00 PM",
maxAccessCount = 1,
currentAccessCount = 1,
notes = "mockNotes-1",
@ -76,7 +76,7 @@ class SendViewExtensionsTest {
),
shareLink = sendUrl,
sendName = "mockName-2",
deletionDate = "27 Oct, 2023, 12:00PM",
deletionDate = "Oct 27, 2023, 12:00 PM",
maxAccessCount = 1,
currentAccessCount = 1,
notes = "mockNotes-2",

View File

@ -169,7 +169,7 @@ fun createCommonContent(
if (isEmpty) {
VaultItemState.ViewState.Content.Common(
name = "mockName",
lastUpdated = "1/1/70 12:16 AM",
lastUpdated = "1/1/70, 12:16 AM",
notes = null,
customFields = emptyList(),
requiresCloneConfirmation = false,
@ -186,7 +186,7 @@ fun createCommonContent(
} else {
VaultItemState.ViewState.Content.Common(
name = "mockName",
lastUpdated = "1/1/70 12:16 AM",
lastUpdated = "1/1/70, 12:16 AM",
notes = "Lots of notes",
customFields = listOf(
FieldView(
@ -267,7 +267,7 @@ fun createLoginContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemT
),
)
},
passwordRevisionDate = "1/1/70 12:16 AM".takeUnless { isEmpty },
passwordRevisionDate = "1/1/70, 12:16 AM".takeUnless { isEmpty },
isPremiumUser = true,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,

View File

@ -1,17 +1,12 @@
package com.bitwarden.core.data.util
import java.time.Clock
import java.time.ZoneId
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle
import java.time.temporal.TemporalAccessor
/**
* Converts the [TemporalAccessor] to a formatted string based on the provided pattern and timezone.
*/
fun TemporalAccessor.toFormattedPattern(
pattern: String,
zone: ZoneId,
): String = DateTimeFormatter.ofPattern(pattern).withZone(zone).format(this)
import java.util.Locale
/**
* Converts the [TemporalAccessor] to a formatted string based on the provided pattern and timezone.
@ -19,4 +14,74 @@ fun TemporalAccessor.toFormattedPattern(
fun TemporalAccessor.toFormattedPattern(
pattern: String,
clock: Clock = Clock.systemDefaultZone(),
): String = toFormattedPattern(pattern = pattern, zone = clock.zone)
): String = DateTimeFormatter.ofPattern(pattern).withZone(clock.zone).format(this)
/**
* Converts the [TemporalAccessor] to a formatted date string based on the provided style, locale,
* and clock.
*
* In US English, the output string will be as follows for the given [dateStyle]:
* * [FormatStyle.SHORT]: 6/6/25
* * [FormatStyle.MEDIUM]: Jun 6, 2025
* * [FormatStyle.LONG]: June 6, 2025
* * [FormatStyle.FULL]: Friday, June 6, 2025
*/
fun TemporalAccessor.toFormattedDateStyle(
dateStyle: FormatStyle,
locale: Locale = Locale.getDefault(),
clock: Clock = Clock.systemDefaultZone(),
): String =
toFormattedPattern(
pattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(
dateStyle,
null,
IsoChronology.INSTANCE,
locale,
),
clock = clock,
)
/**
* Converts the [TemporalAccessor] to a formatted time string based on the provided style, locale,
* and clock.
*
* In US English, the output string will be as follows for the given [timeStyle]:
* * [FormatStyle.SHORT]: 4:15 PM
* * [FormatStyle.MEDIUM]: 4:15:21 PM
* * [FormatStyle.LONG]: 4:15:21 PM CDT
* * [FormatStyle.FULL]: 4:51:03 PM Central Daylight Time
*/
fun TemporalAccessor.toFormattedTimeStyle(
timeStyle: FormatStyle,
locale: Locale = Locale.getDefault(),
clock: Clock = Clock.systemDefaultZone(),
): String =
toFormattedPattern(
pattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(
null,
timeStyle,
IsoChronology.INSTANCE,
locale,
),
clock = clock,
)
/**
* Converts the [TemporalAccessor] to a formatted string based on the provided style, locale, and
* clock.
*/
fun TemporalAccessor.toFormattedDateTimeStyle(
dateStyle: FormatStyle,
timeStyle: FormatStyle,
locale: Locale = Locale.getDefault(),
clock: Clock = Clock.systemDefaultZone(),
): String =
toFormattedPattern(
pattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(
dateStyle,
timeStyle,
IsoChronology.INSTANCE,
locale,
),
clock = clock,
)

View File

@ -4,33 +4,254 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.FormatStyle
import java.util.Locale
class TemporalAccessorExtensionsTest {
@Test
fun `toFormattedPattern should return correctly formatted string with timezone`() {
fun `toFormattedPattern should return correctly formatted string`() {
val instant = Instant.parse("2023-12-10T15:30:00Z")
val pattern = "MM/dd/yyyy hh:mm a"
val zone = ZoneId.of("UTC")
val expectedFormattedString = "12/10/2023 03:30 PM"
val formattedString = instant.toFormattedPattern(pattern, zone)
assertEquals(expectedFormattedString, formattedString)
assertEquals(
"12/10/2023 03:30 PM",
instant.toFormattedPattern(
pattern = "MM/dd/yyyy hh:mm a",
clock = FIXED_CLOCK,
),
)
}
@Test
fun `toFormattedPattern should return correctly formatted string with clock`() {
fun `toFormattedDateStyle should return correctly formatted string with with locale`() {
val instant = Instant.parse("2023-12-10T15:30:00Z")
val pattern = "MM/dd/yyyy hh:mm a"
val clock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
val expectedFormattedString = "12/10/2023 03:30 PM"
val formattedString = instant.toFormattedPattern(pattern, clock)
assertEquals(expectedFormattedString, formattedString)
// US locale
assertEquals(
"12/10/23",
instant.toFormattedDateStyle(
dateStyle = FormatStyle.SHORT,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"Dec 10, 2023",
instant.toFormattedDateStyle(
dateStyle = FormatStyle.MEDIUM,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"December 10, 2023",
instant.toFormattedDateStyle(
dateStyle = FormatStyle.LONG,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"Sunday, December 10, 2023",
instant.toFormattedDateStyle(
dateStyle = FormatStyle.FULL,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
// UK locale
assertEquals(
"10/12/2023",
instant.toFormattedDateStyle(
dateStyle = FormatStyle.SHORT,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"10 Dec 2023",
instant.toFormattedDateStyle(
dateStyle = FormatStyle.MEDIUM,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"10 December 2023",
instant.toFormattedDateStyle(
dateStyle = FormatStyle.LONG,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"Sunday, 10 December 2023",
instant.toFormattedDateStyle(
dateStyle = FormatStyle.FULL,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
}
@Test
fun `toFormattedTimeStyle should return correctly formatted string with with locale`() {
val instant = Instant.parse("2023-12-10T15:30:00Z")
// US locale
assertEquals(
"3:30 PM",
instant.toFormattedTimeStyle(
timeStyle = FormatStyle.SHORT,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"3:30:00 PM",
instant.toFormattedTimeStyle(
timeStyle = FormatStyle.MEDIUM,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"3:30:00 PM Z",
instant.toFormattedTimeStyle(
timeStyle = FormatStyle.LONG,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"3:30:00 PM Z",
instant.toFormattedTimeStyle(
timeStyle = FormatStyle.FULL,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
// UK locale
assertEquals(
"15:30",
instant.toFormattedTimeStyle(
timeStyle = FormatStyle.SHORT,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"15:30:00",
instant.toFormattedTimeStyle(
timeStyle = FormatStyle.MEDIUM,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"15:30:00 Z",
instant.toFormattedTimeStyle(
timeStyle = FormatStyle.LONG,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"15:30:00 Z",
instant.toFormattedTimeStyle(
timeStyle = FormatStyle.FULL,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
}
@Test
fun `toFormattedDateTimeStyle should return correctly formatted string with with locale`() {
val instant = Instant.parse("2023-12-10T15:30:00Z")
// US locale
assertEquals(
"12/10/23, 3:30 PM",
instant.toFormattedDateTimeStyle(
dateStyle = FormatStyle.SHORT,
timeStyle = FormatStyle.SHORT,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"Dec 10, 2023, 3:30:00 PM",
instant.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.MEDIUM,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"December 10, 2023 at 3:30:00 PM Z",
instant.toFormattedDateTimeStyle(
dateStyle = FormatStyle.LONG,
timeStyle = FormatStyle.LONG,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
assertEquals(
"Sunday, December 10, 2023 at 3:30:00 PM Z",
instant.toFormattedDateTimeStyle(
dateStyle = FormatStyle.FULL,
timeStyle = FormatStyle.FULL,
locale = Locale.US,
clock = FIXED_CLOCK,
),
)
// UK locale
assertEquals(
"10/12/2023, 15:30",
instant.toFormattedDateTimeStyle(
dateStyle = FormatStyle.SHORT,
timeStyle = FormatStyle.SHORT,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"10 Dec 2023, 15:30:00",
instant.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.MEDIUM,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"10 December 2023 at 15:30:00 Z",
instant.toFormattedDateTimeStyle(
dateStyle = FormatStyle.LONG,
timeStyle = FormatStyle.LONG,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
assertEquals(
"Sunday, 10 December 2023 at 15:30:00 Z",
instant.toFormattedDateTimeStyle(
dateStyle = FormatStyle.FULL,
timeStyle = FormatStyle.FULL,
locale = Locale.UK,
clock = FIXED_CLOCK,
),
)
}
}
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)