Change color of countdown indicator when period nears expiration (#5)

This commit is contained in:
Patrick Honkonen
2024-03-29 00:02:01 -04:00
committed by GitHub
parent c012e3cb7e
commit bcaf00dc97
13 changed files with 216 additions and 48 deletions

View File

@@ -18,6 +18,24 @@ interface SettingsDiskSource {
*/
val appThemeFlow: Flow<AppTheme>
/**
* Stores the threshold at which users are alerted that an items validity period is nearing
* expiration.
*/
fun storeAlertThresholdSeconds(thresholdSeconds: Int)
/**
* Gets the threshold at which users are alerted that an items validity period is nearing
* expiration.
*/
fun getAlertThresholdSeconds(): Int
/**
* Emits updates that track the threshold at which users are alerted that an items validity
* period is nearing expiration.
*/
fun getAlertThresholdSecondsFlow(): Flow<Int>
/**
* Clears all the settings data for the given user.
*/

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.onSubscription
private const val APP_THEME_KEY = "$BASE_KEY:theme"
private const val SCREEN_CAPTURE_ALLOW_KEY = "$BASE_KEY:screenCaptureAllowed"
private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid"
private const val ALERT_THRESHOLD_SECONDS_KEY = "$BASE_KEY:alertThresholdSeconds"
/**
* Primary implementation of [SettingsDiskSource].
@@ -25,6 +26,9 @@ class SettingsDiskSourceImpl(
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableAlertThresholdSecondsFlow =
bufferedMutableSharedFlow<Int>()
override var appTheme: AppTheme
get() = getString(key = APP_THEME_KEY)
?.let { storedValue ->
@@ -43,6 +47,21 @@ class SettingsDiskSourceImpl(
get() = mutableAppThemeFlow
.onSubscription { emit(appTheme) }
override fun storeAlertThresholdSeconds(thresholdSeconds: Int) {
putInt(
ALERT_THRESHOLD_SECONDS_KEY,
thresholdSeconds
)
mutableAlertThresholdSecondsFlow.tryEmit(thresholdSeconds)
}
override fun getAlertThresholdSeconds(): Int {
return getInt(ALERT_THRESHOLD_SECONDS_KEY, default = 7) ?: 7
}
override fun getAlertThresholdSecondsFlow(): Flow<Int> = mutableAlertThresholdSecondsFlow
.onSubscription { emit(getAlertThresholdSeconds()) }
override fun clearData(userId: String) {
storeScreenCaptureAllowed(userId = userId, isScreenCaptureAllowed = null)
removeWithPrefix(prefix = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_$userId")

View File

@@ -17,4 +17,14 @@ interface SettingsRepository {
* Tracks changes to the [AppTheme].
*/
val appThemeStateFlow: StateFlow<AppTheme>
/**
* The currently stored expiration alert threshold.
*/
var authenticatorAlertThresholdSeconds : Int
/**
* Tracks changes to the expiration alert threshold.
*/
val authenticatorAlertThresholdSecondsFlow: StateFlow<Int>
}

View File

@@ -6,6 +6,7 @@ import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
@@ -20,6 +21,8 @@ class SettingsRepositoryImpl(
override var appTheme: AppTheme by settingsDiskSource::appTheme
override var authenticatorAlertThresholdSeconds = settingsDiskSource.getAlertThresholdSeconds()
override val appThemeStateFlow: StateFlow<AppTheme>
get() = settingsDiskSource
.appThemeFlow
@@ -28,4 +31,15 @@ class SettingsRepositoryImpl(
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource.appTheme,
)
override val authenticatorAlertThresholdSecondsFlow: StateFlow<Int>
get() = settingsDiskSource
.getAlertThresholdSecondsFlow()
.map { it }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource.getAlertThresholdSeconds(),
)
}

View File

@@ -31,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.authenticator.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.authenticator.ui.platform.components.field.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.authenticator.ui.platform.components.icon.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.authenticator.ui.platform.components.indicator.BitwardenCircularCountdownIndicator
@@ -123,11 +124,22 @@ fun ItemContent(
) {
LazyColumn(modifier = modifier) {
item {
BitwardenTextField(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.name),
value = viewState.itemData.name,
onValueChange = {},
readOnly = true,
singleLine = true,
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextFieldWithActions(
label = stringResource(id = R.string.verification_code_totp),
value = viewState.totpCodeItemData.verificationCode
value = viewState.itemData.totpCodeItemData?.verificationCode.orEmpty()
.chunked(AUTH_CODE_SPACING_INTERVAL)
.joinToString(" "),
onValueChange = { },
@@ -135,8 +147,9 @@ fun ItemContent(
singleLine = true,
actions = {
BitwardenCircularCountdownIndicator(
timeLeftSeconds = viewState.totpCodeItemData.timeLeftSeconds,
periodSeconds = viewState.totpCodeItemData.periodSeconds,
timeLeftSeconds = viewState.itemData.totpCodeItemData?.timeLeftSeconds ?: 0,
periodSeconds = viewState.itemData.totpCodeItemData?.periodSeconds ?: 0,
alertThresholdSeconds = viewState.itemData.alertThresholdSeconds
)
BitwardenIconButtonWithResource(
iconRes = IconResource(

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.x8bit.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult
import com.x8bit.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.authenticator.data.platform.repository.model.DataState
import com.x8bit.bitwarden.authenticator.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.item.model.ItemData
@@ -28,6 +29,7 @@ private const val KEY_STATE = "state"
@HiltViewModel
class ItemViewModel @Inject constructor(
authenticatorRepository: AuthenticatorRepository,
settingsRepository: SettingsRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<ItemState, ItemEvent, ItemAction>(
initialState = savedStateHandle[KEY_STATE] ?: ItemState(
@@ -40,21 +42,28 @@ class ItemViewModel @Inject constructor(
init {
combine(
authenticatorRepository.getItemStateFlow(state.itemId),
authenticatorRepository.getAuthCodeFlow(state.itemId)
) { itemState, authCodeState ->
val totpData = authCodeState.data?.let {
TotpCodeItemData(
periodSeconds = it.periodSeconds,
timeLeftSeconds = it.timeLeftSeconds,
totpCode = it.totpCode,
verificationCode = it.code
)
}
authenticatorRepository.getAuthCodeFlow(state.itemId),
settingsRepository.authenticatorAlertThresholdSecondsFlow,
) { itemState, authCodeState, alertThresholdSeconds ->
ItemAction.Internal.ItemDataReceive(
item = itemState.data,
itemDataState = combineDataStates(itemState, authCodeState) { item, _ ->
itemDataState = combineDataStates(
itemState,
authCodeState,
) { item, authCode ->
val totpData = authCode?.let {
TotpCodeItemData(
periodSeconds = it.periodSeconds,
timeLeftSeconds = it.timeLeftSeconds,
totpCode = it.totpCode,
verificationCode = it.code
)
}
ItemData(
accountName = itemState.data?.name.orEmpty(),
name = item?.name.orEmpty(),
alertThresholdSeconds = alertThresholdSeconds,
totpCodeItemData = totpData
)
}
@@ -122,7 +131,11 @@ class ItemViewModel @Inject constructor(
it.copy(
itemId = action.item?.id.orEmpty(),
viewState = ItemState.ViewState.Content(
totpCodeItemData = totpItemData
itemData = ItemData(
name = action.item?.name.orEmpty(),
alertThresholdSeconds = action.itemDataState.data?.alertThresholdSeconds ?: 0,
totpCodeItemData = totpItemData,
)
)
)
}
@@ -167,7 +180,7 @@ data class ItemState(
*/
@Parcelize
data class Content(
val totpCodeItemData: TotpCodeItemData,
val itemData: ItemData,
) : ViewState()
}

View File

@@ -6,11 +6,14 @@ import kotlinx.parcelize.Parcelize
/**
* Represents the item data displayed to users.
*
* @property accountName Name of the account associated to the item.
* @property name Name of the account associated to the item.
* @property alertThresholdSeconds Threshold, in seconds, at which an Item is considered near
* expiration.
* @property totpCodeItemData TOTP data for the account.
*/
@Parcelize
data class ItemData(
val accountName: String,
val name: String,
val alertThresholdSeconds: Int,
val totpCodeItemData: TotpCodeItemData?,
) : Parcelable

View File

@@ -111,19 +111,20 @@ fun ItemListingScreen(
LazyColumn {
items(currentState.itemList) {
VaultVerificationCodeItem(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
startIcon = it.startIcon,
label = it.label,
supportingLabel = it.supportingLabel,
timeLeftSeconds = it.timeLeftSeconds,
periodSeconds = it.periodSeconds,
alertThresholdSeconds = it.alertThresholdSeconds,
authCode = it.authCode,
onCopyClick = { /*TODO*/ },
onItemClick = {
onNavigateToItemScreen(it.id)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}

View File

@@ -5,12 +5,15 @@ import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.x8bit.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.authenticator.data.platform.repository.model.DataState
import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.ItemListingData
import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText
import com.x8bit.bitwarden.authenticator.ui.platform.components.model.IconData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@@ -23,6 +26,7 @@ import javax.inject.Inject
@HiltViewModel
class ItemListingViewModel @Inject constructor(
authenticatorRepository: AuthenticatorRepository,
settingsRepository: SettingsRepository,
) :
BaseViewModel<ItemListingState, ItemListingEvent, ItemListingAction>(
initialState = ItemListingState(
@@ -32,7 +36,53 @@ class ItemListingViewModel @Inject constructor(
) {
init {
authenticatorRepository.getAuthCodesFlow()
combine(
authenticatorRepository.getAuthCodesFlow(),
settingsRepository.authenticatorAlertThresholdSecondsFlow,
) { authCodeState, alertThresholdSeconds ->
when (authCodeState) {
is DataState.Error -> {
DataState.Error(
error = authCodeState.error,
data = ItemListingData(
alertThresholdSeconds = alertThresholdSeconds,
authenticatorData = authCodeState.data,
)
)
}
is DataState.Loaded -> {
DataState.Loaded(
ItemListingData(
alertThresholdSeconds = alertThresholdSeconds,
authCodeState.data
)
)
}
DataState.Loading -> {
DataState.Loading
}
is DataState.NoNetwork -> {
DataState.NoNetwork(
ItemListingData(
alertThresholdSeconds = alertThresholdSeconds,
authenticatorData = authCodeState.data ?: emptyList()
)
)
}
is DataState.Pending -> {
DataState.Pending(
ItemListingData(
alertThresholdSeconds = alertThresholdSeconds,
authCodeState.data
)
)
}
}
}
.onEach {
sendAction(ItemListingAction.Internal.AuthenticatorDataReceive(it))
}
@@ -59,16 +109,16 @@ class ItemListingViewModel @Inject constructor(
}
private fun handleAuthenticatorDataReceive(action: ItemListingAction.Internal.AuthenticatorDataReceive) {
when (val authenticatorData = action.authenticatorData) {
is DataState.Error -> authenticatorErrorReceive(authenticatorData)
is DataState.Loaded -> authenticatorDataLoadedReceive(authenticatorData)
DataState.Loading -> authenticatorDataLoadingReceive(authenticatorData)
is DataState.NoNetwork -> authenticatorNoNetworkReceive(authenticatorData)
when (val viewState = action.itemListingDataState) {
is DataState.Error -> authenticatorErrorReceive(viewState)
is DataState.Loaded -> authenticatorDataLoadedReceive(viewState.data)
is DataState.Loading -> authenticatorDataLoadingReceive()
is DataState.NoNetwork -> authenticatorNoNetworkReceive(viewState.data)
is DataState.Pending -> authenticatorPendingReceive()
}
}
private fun authenticatorErrorReceive(authenticatorData: DataState<List<VerificationCodeItem>>) {
private fun authenticatorErrorReceive(authenticatorData: DataState.Error<ItemListingData>) {
mutableStateFlow.update {
if (authenticatorData.data != null) {
val viewState = updateViewState(authenticatorData.data)
@@ -90,31 +140,31 @@ class ItemListingViewModel @Inject constructor(
}
}
private fun authenticatorDataLoadedReceive(authenticatorData: DataState<List<VerificationCodeItem>>) {
private fun authenticatorDataLoadedReceive(authenticatorData: ItemListingData) {
if (state.dialog == ItemListingState.DialogState.Syncing) {
sendEvent(ItemListingEvent.ShowToast(R.string.syncing_complete.asText()))
}
mutableStateFlow.update {
it.copy(
viewState = updateViewState(authenticatorData.data),
viewState = updateViewState(authenticatorData),
dialog = null
)
}
}
private fun authenticatorDataLoadingReceive(authenticatorData: DataState<List<VerificationCodeItem>>) {
private fun authenticatorDataLoadingReceive() {
mutableStateFlow.update {
it.copy(
viewState = updateViewState(authenticatorData.data),
viewState = ItemListingState.ViewState.Loading,
dialog = ItemListingState.DialogState.Syncing
)
}
}
private fun authenticatorNoNetworkReceive(authenticatorData: DataState<List<VerificationCodeItem>>) {
private fun authenticatorNoNetworkReceive(authenticatorData: ItemListingData?) {
mutableStateFlow.update {
it.copy(
viewState = updateViewState(authenticatorData.data),
viewState = updateViewState(authenticatorData),
dialog = ItemListingState.DialogState.Error(
title = R.string.internet_connection_required_title.asText(),
message = R.string.internet_connection_required_message.asText(),
@@ -131,12 +181,14 @@ class ItemListingViewModel @Inject constructor(
}
}
private fun updateViewState(verificationCodeItems: List<VerificationCodeItem>?) =
if (verificationCodeItems.isNullOrEmpty()) {
ItemListingState.ViewState.NoItems
} else {
ItemListingState.ViewState.Content(verificationCodeItems.toDisplayItems())
}
private fun updateViewState(
itemListingData: ItemListingData?,
): ItemListingState.ViewState {
val items = itemListingData?.authenticatorData ?: return ItemListingState.ViewState.NoItems
return ItemListingState.ViewState.Content(
itemList = items.toDisplayItems(itemListingData.alertThresholdSeconds)
)
}
}
/**
@@ -285,7 +337,7 @@ sealed class ItemListingAction {
* Indicates authenticator item listing data has been received.
*/
data class AuthenticatorDataReceive(
val authenticatorData: DataState<List<VerificationCodeItem>>,
val itemListingDataState: DataState<ItemListingData>,
) : Internal()
}
}
@@ -300,11 +352,12 @@ data class VerificationCodeDisplayItem(
val supportingLabel: String?,
val timeLeftSeconds: Int,
val periodSeconds: Int,
val alertThresholdSeconds: Int,
val authCode: String,
val startIcon: IconData = IconData.Local(R.drawable.ic_login_item),
) : Parcelable
private fun List<VerificationCodeItem>.toDisplayItems() = this.map {
private fun List<VerificationCodeItem>.toDisplayItems(alertThresholdSeconds: Int) = this.map {
VerificationCodeDisplayItem(
id = it.id,
label = it.name,
@@ -312,5 +365,6 @@ private fun List<VerificationCodeItem>.toDisplayItems() = this.map {
supportingLabel = it.username,
periodSeconds = it.periodSeconds,
timeLeftSeconds = it.timeLeftSeconds,
alertThresholdSeconds = alertThresholdSeconds
)
}

View File

@@ -48,6 +48,7 @@ fun VaultVerificationCodeItem(
label: String,
periodSeconds: Int,
timeLeftSeconds: Int,
alertThresholdSeconds: Int,
startIcon: IconData,
onCopyClick: () -> Unit,
onItemClick: () -> Unit,
@@ -101,6 +102,7 @@ fun VaultVerificationCodeItem(
BitwardenCircularCountdownIndicator(
timeLeftSeconds = timeLeftSeconds,
periodSeconds = periodSeconds,
alertThresholdSeconds = alertThresholdSeconds
)
Text(
@@ -137,6 +139,7 @@ private fun VerificationCodeItem_preview() {
onCopyClick = {},
onItemClick = {},
modifier = Modifier.padding(horizontal = 16.dp),
alertThresholdSeconds = 7
)
}
}

View File

@@ -0,0 +1,8 @@
package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model
import com.x8bit.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
data class ItemListingData(
val alertThresholdSeconds: Int,
val authenticatorData: List<VerificationCodeItem>?,
)

View File

@@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
@@ -24,9 +25,11 @@ import androidx.compose.ui.unit.dp
*/
@Composable
fun BitwardenCircularCountdownIndicator(
modifier: Modifier = Modifier,
timeLeftSeconds: Int,
periodSeconds: Int,
modifier: Modifier = Modifier,
alertThresholdSeconds: Int = -1,
alertIndicatorColor: Color = MaterialTheme.colorScheme.error,
) {
val progressAnimate by animateFloatAsState(
targetValue = timeLeftSeconds.toFloat() / periodSeconds,
@@ -45,7 +48,11 @@ fun BitwardenCircularCountdownIndicator(
CircularProgressIndicator(
progress = { progressAnimate },
modifier = Modifier.size(size = 30.dp),
color = MaterialTheme.colorScheme.primary,
color = if (timeLeftSeconds > alertThresholdSeconds) {
MaterialTheme.colorScheme.primary
} else {
alertIndicatorColor
},
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round,
)
@@ -53,7 +60,11 @@ fun BitwardenCircularCountdownIndicator(
Text(
text = timeLeftSeconds.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = if (timeLeftSeconds > alertThresholdSeconds) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
alertIndicatorColor
},
)
}
}

View File

@@ -5,7 +5,7 @@
<string name="add_item">Add item</string>
<string name="an_error_has_occurred">An error has occurred.</string>
<string name="generic_error_message">We were unable to process your request. Please try again or contact us.</string>
<string name="syncing_complete">SyncingComplete</string>
<string name="syncing_complete">Syncing Complete</string>
<string name="internet_connection_required_title">Internet connection required</string>
<string name="internet_connection_required_message">Please connect to the internet before continuing.</string>
<string name="ok">OK</string>
@@ -17,4 +17,5 @@
<string name="close">Close</string>
<string name="view_item">View Item</string>
<string name="bitwarden_authenticator">Bitwarden Authenticator</string>
</resources>
<string name="name">Name</string>
</resources>