Year-month date picker implementation

This commit is contained in:
Milan Cerovsky 2025-03-12 19:38:15 +01:00
parent cee77ea0f9
commit 43e1c36dfb
11 changed files with 578 additions and 2 deletions

View File

@ -0,0 +1,18 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
@Composable
fun VerticalSpacer(height: Dp) {
Spacer(Modifier.height(height))
}
@Composable
fun HorizontalSpacer(width: Dp) {
Spacer(Modifier.width(width))
}

View File

@ -0,0 +1,292 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import kotlinx.coroutines.launch
import java.text.DateFormatSymbols
import java.time.Month
import java.time.Year
import java.time.YearMonth
import kotlin.math.pow
@Suppress("MagicNumber")
@Composable
fun ZashiYearMonthWheelDatePicker(
modifier: Modifier = Modifier,
verticallyVisibleItems: Int = 3,
startYear: Year = Year.of(2016),
endYear: Year = Year.now(),
selectedYear: YearMonth = YearMonth.now(),
onSelectionChanged: (YearMonth) -> Unit,
) {
val latestOnSelectionChanged by rememberUpdatedState(onSelectionChanged)
var selectedDate by remember { mutableStateOf(selectedYear) }
val months =
listOf(
Month.JANUARY,
Month.FEBRUARY,
Month.MARCH,
Month.APRIL,
Month.MAY,
Month.JUNE,
Month.JULY,
Month.AUGUST,
Month.SEPTEMBER,
Month.OCTOBER,
Month.NOVEMBER,
Month.DECEMBER
)
val years = (startYear.value..endYear.value).toList()
LaunchedEffect(selectedDate) {
Twig.debug { "Selection changed: $selectedDate" }
latestOnSelectionChanged(selectedDate)
}
Box(modifier = modifier) {
Column(
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.Center),
) {
ZashiHorizontalDivider(color = ZashiColors.Surfaces.bgQuaternary, thickness = .5.dp)
VerticalSpacer(31.dp)
ZashiHorizontalDivider(color = ZashiColors.Surfaces.bgQuaternary, thickness = .5.dp)
}
Row(
horizontalArrangement = Arrangement.Center
) {
Spacer(Modifier.weight(.5f))
WheelLazyList(
modifier = Modifier.weight(1f),
selection = maxOf(months.indexOf(selectedDate.month), 0),
itemCount = months.size,
itemVerticalOffset = verticallyVisibleItems,
isInfiniteScroll = true,
onFocusItem = { selectedDate = selectedDate.withMonth(months[it].value) },
itemContent = {
Text(
text = DateFormatSymbols().months[months[it].value - 1],
textAlign = TextAlign.Center,
modifier = Modifier.fillParentMaxWidth(),
style = ZashiTypography.header6,
color = ZashiColors.Text.textPrimary,
maxLines = 1
)
}
)
WheelLazyList(
modifier = Modifier.weight(.75f),
selection = years.indexOf(selectedDate.year),
itemCount = years.size,
itemVerticalOffset = verticallyVisibleItems,
isInfiniteScroll = false,
onFocusItem = { selectedDate = selectedDate.withYear(years[it]) },
itemContent = {
Text(
text = years[it].toString(),
textAlign = TextAlign.Center,
modifier = Modifier.fillParentMaxWidth(),
style = ZashiTypography.header6,
color = ZashiColors.Text.textPrimary,
maxLines = 1
)
}
)
Spacer(Modifier.weight(.5f))
}
}
}
@Suppress("MagicNumber")
@Composable
private fun WheelLazyList(
itemCount: Int,
selection: Int,
itemVerticalOffset: Int,
onFocusItem: (Int) -> Unit,
isInfiniteScroll: Boolean,
itemContent: @Composable LazyItemScope.(index: Int) -> Unit,
modifier: Modifier = Modifier,
) {
val latestOnFocusItem by rememberUpdatedState(onFocusItem)
val coroutineScope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
val count = if (isInfiniteScroll) itemCount else itemCount + 2 * itemVerticalOffset
val rowOffsetCount = maxOf(1, minOf(itemVerticalOffset, 4))
val rowCount = (rowOffsetCount * 2) + 1
val startIndex = if (isInfiniteScroll) selection + (itemCount * 1000) - itemVerticalOffset else selection
val state = rememberLazyListState(startIndex)
val itemHeightPx = with(LocalDensity.current) { 27.dp.toPx() }
val height = 32.dp * rowCount
val isScrollInProgress = state.isScrollInProgress
LaunchedEffect(itemCount) {
coroutineScope.launch {
state.scrollToItem(startIndex)
}
}
LaunchedEffect(key1 = isScrollInProgress) {
if (!isScrollInProgress) {
calculateIndexToFocus(state, height).let {
val indexToFocus =
if (isInfiniteScroll) {
(it + rowOffsetCount) % itemCount
} else {
((it + rowOffsetCount) % count) - itemVerticalOffset
}
latestOnFocusItem(indexToFocus)
if (state.firstVisibleItemScrollOffset != 0) {
coroutineScope.launch {
state.animateScrollToItem(it, 0)
}
}
}
}
}
LaunchedEffect(state) {
snapshotFlow { state.firstVisibleItemIndex }
.collect {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
Box(
modifier =
modifier
.height(height)
.fillMaxWidth(),
) {
LazyColumn(
modifier =
Modifier
.height(height)
.fillMaxWidth(),
state = state,
) {
items(if (isInfiniteScroll) Int.MAX_VALUE else count) { index ->
val (scale, alpha, translationY) =
remember {
derivedStateOf {
val info = state.layoutInfo
val middleOffset = info.viewportSize.height / 2
val item = info.visibleItemsInfo.firstOrNull { it.index == index }
val scrollOffset = if (item != null) item.offset + item.size / 2 else -1
val coefficient = calculateCoefficient(middleOffset = middleOffset, offset = scrollOffset)
val scale = calculateScale(coefficient)
val alpha = calculateAlpha(coefficient)
val translationY =
calculateTranslationY(
coefficient = coefficient,
itemHeightPx = itemHeightPx,
middleOffset = middleOffset,
offset = scrollOffset
)
Triple(scale, alpha, translationY)
}
}.value
Box(
modifier =
Modifier
.height(height / rowCount)
.fillMaxWidth()
.graphicsLayer {
this.alpha = alpha
this.scaleX = scale
this.scaleY = scale
this.translationY = translationY
},
contentAlignment = Alignment.Center,
) {
if (isInfiniteScroll) {
itemContent(index % itemCount)
} else if (index >= rowOffsetCount && index < itemCount + rowOffsetCount) {
itemContent((index - rowOffsetCount) % itemCount)
}
}
}
}
}
}
@Suppress("MagicNumber")
private fun calculateCoefficient(
middleOffset: Int,
offset: Int
): Float {
val diff = if (middleOffset > offset) middleOffset - offset else offset - middleOffset
return (1f - (diff.toFloat() / middleOffset.toFloat())).coerceAtLeast(0f)
}
@Suppress("MagicNumber")
private fun calculateScale(coefficient: Float): Float {
return coefficient.coerceAtLeast(.6f)
}
@Suppress("MagicNumber")
private fun calculateAlpha(coefficient: Float): Float {
return coefficient.pow(1.1f)
}
@Suppress("MagicNumber")
private fun calculateTranslationY(
coefficient: Float,
itemHeightPx: Float,
middleOffset: Int,
offset: Int
): Float {
// if (coefficient in 0.66f..1f) return 0f
val exponentialCoefficient = 1.2f - 5f.pow(-(coefficient))
val offsetBy = (1 - exponentialCoefficient) * itemHeightPx
return if (middleOffset > offset) offsetBy else -offsetBy
}
@Suppress("MagicNumber")
private fun calculateIndexToFocus(
listState: LazyListState,
height: Dp
): Int {
val currentItem = listState.layoutInfo.visibleItemsInfo.firstOrNull()
var index = currentItem?.index ?: 0
if (currentItem?.offset != 0 && currentItem != null && currentItem.offset <= -height.value * 3 / 10) {
index++
}
return index
}

View File

@ -19,6 +19,7 @@ import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewMo
import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel
import co.electriccoin.zcash.ui.screen.restore.date.RestoreBDDateViewModel
import co.electriccoin.zcash.ui.screen.restore.height.RestoreBDHeightViewModel
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel
@ -156,4 +157,5 @@ val viewModelModule =
viewModelOf(::BalanceViewModel)
viewModelOf(::HomeViewModel)
viewModelOf(::RestoreBDHeightViewModel)
viewModelOf(::RestoreBDDateViewModel)
}

View File

@ -29,6 +29,8 @@ import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popEnterTransit
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition
import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
import co.electriccoin.zcash.ui.screen.onboarding.view.Onboarding
import co.electriccoin.zcash.ui.screen.restore.date.AndroidRestoreBDDate
import co.electriccoin.zcash.ui.screen.restore.date.RestoreBDDate
import co.electriccoin.zcash.ui.screen.restore.height.AndroidRestoreBDHeight
import co.electriccoin.zcash.ui.screen.restore.height.RestoreBDHeight
import co.electriccoin.zcash.ui.screen.restore.seed.AndroidRestoreSeed
@ -101,6 +103,9 @@ fun MainActivity.RestoreNavigation() {
composable<RestoreBDHeight> {
AndroidRestoreBDHeight()
}
composable<RestoreBDDate> {
AndroidRestoreBDDate()
}
}
}

View File

@ -0,0 +1,24 @@
package co.electriccoin.zcash.ui.screen.restore.date
import androidx.activity.compose.BackHandler
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.screen.restore.RestoreSeedDialog
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AndroidRestoreBDDate() {
val vm = koinViewModel<RestoreBDDateViewModel>()
val state by vm.state.collectAsStateWithLifecycle()
val dialogState by vm.dialogState.collectAsStateWithLifecycle()
RestoreBDDateView(state)
BackHandler { state.onBack() }
RestoreSeedDialog(dialogState)
}
@Serializable
data object RestoreBDDate

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.ui.screen.restore.date
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
data class RestoreBDDateState(
val next: ButtonState,
val dialogButton: IconButtonState,
val onBack: () -> Unit
)

View File

@ -0,0 +1,156 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.restore.date
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarTags
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiIconButton
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.component.ZashiYearMonthWheelDatePicker
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
@Composable
fun RestoreBDDateView(state: RestoreBDDateState) {
BlankBgScaffold(
topBar = { AppBar(state) },
bottomBar = {},
content = { padding ->
Content(
state = state,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.scaffoldPadding(padding)
)
}
)
}
@Composable
private fun Content(
state: RestoreBDDateState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
) {
Text(
text = stringResource(R.string.restore_bd_date_subtitle),
style = ZashiTypography.header6,
color = ZashiColors.Text.textPrimary,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.restore_bd_date_message),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textPrimary
)
Spacer(Modifier.height(24.dp))
ZashiYearMonthWheelDatePicker(
modifier = Modifier.fillMaxWidth()
) {}
Spacer(Modifier.height(24.dp))
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Image(
painterResource(R.drawable.ic_info),
contentDescription = "",
colorFilter = ColorFilter.tint(color = ZashiColors.Utility.Indigo.utilityIndigo700)
)
Spacer(Modifier.width(8.dp))
Text(
modifier = Modifier.padding(top = 2.dp),
text = stringResource(R.string.restore_bd_date_note),
style = ZashiTypography.textXs,
fontWeight = FontWeight.Medium,
color = ZashiColors.Utility.Indigo.utilityIndigo700
)
}
Spacer(Modifier.height(24.dp))
ZashiButton(
state.next,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun AppBar(state: RestoreBDDateState) {
ZashiSmallTopAppBar(
title = stringResource(R.string.restore_title),
navigationAction = {
ZashiTopAppBarBackNavigation(
onBack = state.onBack,
modifier = Modifier.testTag(ZashiTopAppBarTags.BACK)
)
},
regularActions = {
ZashiIconButton(state.dialogButton, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(20.dp))
},
colors =
ZcashTheme.colors.topAppBarColors orDark
ZcashTheme.colors.topAppBarColors.copyColors(
containerColor = Color.Transparent
),
)
}
@PreviewScreens
@Composable
private fun Preview() =
ZcashTheme {
RestoreBDDateView(
state =
RestoreBDDateState(
next = ButtonState(stringRes("Estimate")) {},
dialogButton = IconButtonState(R.drawable.ic_restore_dialog) {},
onBack = {}
)
)
}

View File

@ -0,0 +1,63 @@
package co.electriccoin.zcash.ui.screen.restore.date
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.restore.RestoreSeedDialogState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class RestoreBDDateViewModel(
private val navigationRouter: NavigationRouter
) : ViewModel() {
private val isDialogVisible = MutableStateFlow(false)
val dialogState =
isDialogVisible
.map { isDialogVisible ->
RestoreSeedDialogState(
::onCloseDialogClick
).takeIf { isDialogVisible }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
val state: StateFlow<RestoreBDDateState> = MutableStateFlow(createState()).asStateFlow()
private fun createState() =
RestoreBDDateState(
next = ButtonState(stringRes(R.string.restore_bd_height_btn), onClick = ::onEstimateClick),
dialogButton = IconButtonState(icon = R.drawable.ic_info, onClick = ::onInfoButtonClick),
onBack = ::onBack,
)
private fun onEstimateClick() {
// do nothing
}
private fun onBack() {
navigationRouter.back()
}
private fun onInfoButtonClick() {
isDialogVisible.update { true }
}
private fun onCloseDialogClick() {
isDialogVisible.update { false }
}
}

View File

@ -10,6 +10,7 @@ import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.restore.RestoreSeedDialogState
import co.electriccoin.zcash.ui.screen.restore.date.RestoreBDDate
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -59,7 +60,7 @@ class RestoreBDHeightViewModel(
)
private fun onEstimateClick() {
// do nothing
navigationRouter.forward(RestoreBDDate)
}
private fun onRestoreClick() {

View File

@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.update
class RestoreSeedViewModel(
private val navigationRouter: NavigationRouter
) : ViewModel() {
@Suppress("MagicNumber")
private val seedWords =
MutableStateFlow(

View File

@ -26,4 +26,10 @@
<string name="restore_bd_text_field_hint">Enter number</string>
<string name="restore_bd_text_field_note">Wallet Birthday Height is the point in time when your wallet was created.</string>
<string name="restore_bd_date_subtitle">First Wallet Transaction</string>
<string name="restore_bd_date_message">Entering the block height at which your wallet was created reduces the
number of blocks that need to be scanned to recover your wallet.</string>
<string name="restore_bd_date_note">If youre not sure, choose an earlier date.</string>
<string name="restore_bd_date_next">Next</string>
</resources>