diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..1b66a66 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/CHANGELOG.md b/CHANGELOG.md index 818b1d1..2d93918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Version 1.1.1: +- **New**: Added entry animations for carts and cart items. - **New**: Improved cart screen layout to better distinguish between in-cart and pickup items. - **Minor**: Enhanced snack bar animations for a more engaging user experience. - **Minor**: Added support for Traditional Chinese. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fc0c67b..82750c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,7 +15,7 @@ android { applicationId = "com.d4rk.cartcalculator" minSdk = 23 targetSdk = 35 - versionCode = 67 + versionCode = 68 versionName = "1.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resourceConfigurations += listOf( diff --git a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/components/animations/Animations.kt b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/components/animations/Animations.kt index afd00ac..27b4162 100644 --- a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/components/animations/Animations.kt +++ b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/components/animations/Animations.kt @@ -2,8 +2,11 @@ package com.d4rk.cartcalculator.ui.components.animations import android.content.Context import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.material3.DrawerState import androidx.compose.material3.SwipeToDismissBoxState import androidx.compose.material3.SwipeToDismissBoxValue @@ -22,6 +25,8 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp import com.d4rk.cartcalculator.data.datastore.DataStore import com.d4rk.cartcalculator.data.model.ui.button.ButtonState @@ -80,18 +85,47 @@ fun Modifier.hapticDrawerSwipe(drawerState : DrawerState) : Modifier = composed return@composed this } -fun Modifier.hapticSwipeToDismissBox(swipeToDismissBoxState: SwipeToDismissBoxState): Modifier = composed { - val haptic: HapticFeedback = LocalHapticFeedback.current - var hasVibrated by remember { mutableStateOf(value = false) } +fun Modifier.hapticSwipeToDismissBox(swipeToDismissBoxState : SwipeToDismissBoxState) : Modifier = + composed { + val haptic : HapticFeedback = LocalHapticFeedback.current + var hasVibrated by remember { mutableStateOf(value = false) } - LaunchedEffect(swipeToDismissBoxState.currentValue) { - if (swipeToDismissBoxState.currentValue != SwipeToDismissBoxValue.Settled && !hasVibrated) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - hasVibrated = true - } else if (swipeToDismissBoxState.currentValue == SwipeToDismissBoxValue.Settled) { - hasVibrated = false + LaunchedEffect(swipeToDismissBoxState.currentValue) { + if (swipeToDismissBoxState.currentValue != SwipeToDismissBoxValue.Settled && ! hasVibrated) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + hasVibrated = true + } + else if (swipeToDismissBoxState.currentValue == SwipeToDismissBoxValue.Settled) { + hasVibrated = false + } + } + + return@composed this } - } - return@composed this +fun Modifier.animateVisibility( + visible : Boolean = true , + index : Int = 0 , + offsetY : Int = 50 , + durationMillis : Int = 300 , + delayPerItem : Int = 64 +) = composed { + val alpha = animateFloatAsState( + targetValue = if (visible) 1f else 0f , animationSpec = tween( + durationMillis = durationMillis , delayMillis = index * delayPerItem + ) , label = "Alpha" + ) + + val offsetYState = animateFloatAsState( + targetValue = if (visible) 0f else offsetY.toFloat() , animationSpec = tween( + durationMillis = durationMillis , delayMillis = index * delayPerItem + ) , label = "OffsetY" + ) + + this + .offset { IntOffset(x = 0 , offsetYState.value.toInt()) } + .graphicsLayer { + this.alpha = alpha.value + } + .padding(vertical = 4.dp) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/cart/CartScreen.kt b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/cart/CartScreen.kt index 502acd8..1913efe 100644 --- a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/cart/CartScreen.kt +++ b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/cart/CartScreen.kt @@ -21,7 +21,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -68,6 +68,7 @@ import com.d4rk.cartcalculator.data.database.table.ShoppingCartItemsTable import com.d4rk.cartcalculator.data.datastore.DataStore import com.d4rk.cartcalculator.data.model.ui.screens.UiCartModel import com.d4rk.cartcalculator.ui.components.ads.AdBanner +import com.d4rk.cartcalculator.ui.components.animations.animateVisibility import com.d4rk.cartcalculator.ui.components.animations.bounceClick import com.d4rk.cartcalculator.ui.components.animations.hapticSwipeToDismissBox import com.d4rk.cartcalculator.ui.components.dialogs.AddNewCartItemAlertDialog @@ -86,33 +87,34 @@ fun CartScreen(activity : CartActivity , cartId : Int) { val uiState : UiCartModel by viewModel.uiState.collectAsState() val isLoading : Boolean by viewModel.isLoading.collectAsState() + val visibilityStates by viewModel.visibilityStates.collectAsState() val dataStore = DataStore.getInstance(context) Box(modifier = Modifier.fillMaxSize()) { - Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) , - topBar = { - LargeTopAppBar(title = { - Text( - text = uiState.cart?.name - ?: stringResource(id = R.string.shopping_cart) - ) - } , navigationIcon = { - IconButton(onClick = { - activity.finish() - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack , contentDescription = null) - } - } , actions = { - IconButton(onClick = { - viewModel.toggleOpenDialog() - }) { - Icon( - Icons.Outlined.AddShoppingCart , contentDescription = null , - ) - } - } , scrollBehavior = scrollBehavior) - }) { paddingValues -> + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) , + topBar = { + LargeTopAppBar(title = { + Text( + text = uiState.cart?.name ?: stringResource(id = R.string.shopping_cart) + ) + } , navigationIcon = { + IconButton(onClick = { + activity.finish() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack , contentDescription = null) + } + } , actions = { + IconButton(onClick = { + viewModel.toggleOpenDialog() + }) { + Icon( + Icons.Outlined.AddShoppingCart , contentDescription = null , + ) + } + } , scrollBehavior = scrollBehavior) + }) { paddingValues -> Box( modifier = Modifier .padding(paddingValues) @@ -132,7 +134,7 @@ fun CartScreen(activity : CartActivity , cartId : Int) { } else { - val (checkedItems, uncheckedItems) = uiState.cartItems.partition { it.isChecked } + val (checkedItems , uncheckedItems) = uiState.cartItems.partition { it.isChecked } Box { Column( modifier = Modifier.fillMaxSize() @@ -143,21 +145,27 @@ fun CartScreen(activity : CartActivity , cartId : Int) { if (checkedItems.isNotEmpty()) { item { Text( - text = stringResource(id = R.string.in_cart), + text = stringResource(id = R.string.in_cart) , style = MaterialTheme.typography.titleMedium , - modifier = Modifier.padding(start = 16.dp , top = 8.dp).animateItem() + modifier = Modifier + .padding(start = 16.dp , top = 8.dp) + .animateItem() ) } - items( - items = checkedItems , - key = { item -> item.itemId }) { cartItem -> + itemsIndexed(items = checkedItems , + key = { _ , item -> item.itemId }) { index , cartItem -> + val isVisible = visibilityStates.getOrElse(index) { false } CartItemComposable( viewModel = viewModel , cartItem = cartItem , onMinusClick = { viewModel.decreaseQuantity(cartItem) } , onPlusClick = { viewModel.increaseQuantity(cartItem) } , uiState = uiState , - modifier = Modifier.animateItem() + modifier = Modifier + .animateItem() + .animateVisibility( + visible = isVisible , index = index + ) ) } } @@ -165,21 +173,27 @@ fun CartScreen(activity : CartActivity , cartId : Int) { if (uncheckedItems.isNotEmpty()) { item { Text( - text = stringResource(id = R.string.items_to_pick_up), + text = stringResource(id = R.string.items_to_pick_up) , style = MaterialTheme.typography.titleMedium , - modifier = Modifier.padding(start = 16.dp , top = 8.dp).animateItem() + modifier = Modifier + .padding(start = 16.dp , top = 8.dp) + .animateItem() ) } - items( - items = uncheckedItems , - key = { item -> item.itemId }) { cartItem -> + itemsIndexed(items = uncheckedItems , + key = { _ , item -> item.itemId }) { index , cartItem -> + val isVisible = visibilityStates.getOrElse(index) { false } CartItemComposable( viewModel = viewModel , cartItem = cartItem , onMinusClick = { viewModel.decreaseQuantity(cartItem) } , onPlusClick = { viewModel.increaseQuantity(cartItem) } , uiState = uiState , - modifier = Modifier.animateItem() + modifier = Modifier + .animateItem() + .animateVisibility( + visible = isVisible , index = index + ) ) } } @@ -357,8 +371,7 @@ fun CartItemComposable( view.playSoundEffect(SoundEffectConstants.CLICK) checkedState = isChecked viewModel.onItemCheckedChange( - cartItem , - isChecked + cartItem , isChecked ) }) Column { diff --git a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/cart/CartViewModel.kt b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/cart/CartViewModel.kt index ed20edb..f71bfb1 100644 --- a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/cart/CartViewModel.kt +++ b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/cart/CartViewModel.kt @@ -7,6 +7,7 @@ import com.d4rk.cartcalculator.data.datastore.DataStore import com.d4rk.cartcalculator.data.model.ui.screens.UiCartModel import com.d4rk.cartcalculator.ui.screens.cart.repository.CartRepository import com.d4rk.cartcalculator.ui.viewmodel.BaseViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.firstOrNull @@ -33,6 +34,20 @@ class CartViewModel(application : Application) : BaseViewModel(application) { calculateTotalPrice() } hideLoading() + initializeVisibilityStates() + } + } + + private fun initializeVisibilityStates() { + viewModelScope.launch(coroutineExceptionHandler) { + delay(timeMillis = 50L) + _visibilityStates.value = List(_uiState.value.cartItems.size) { false } + _uiState.value.cartItems.indices.forEach { index -> + delay(timeMillis = index * 8L) + _visibilityStates.value = List(_visibilityStates.value.size) { lessonIndex -> + lessonIndex == index || _visibilityStates.value[lessonIndex] + } + } } } diff --git a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/home/HomeScreen.kt index 2649ef6..beacd2a 100644 --- a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/home/HomeScreen.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DeleteForever @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import com.d4rk.cartcalculator.R import com.d4rk.cartcalculator.data.database.table.ShoppingCartTable import com.d4rk.cartcalculator.data.model.ui.screens.UiHomeModel +import com.d4rk.cartcalculator.ui.components.animations.animateVisibility import com.d4rk.cartcalculator.ui.components.animations.bounceClick import com.d4rk.cartcalculator.ui.components.animations.hapticSwipeToDismissBox import com.d4rk.cartcalculator.ui.components.dialogs.AddNewCartAlertDialog @@ -56,6 +57,7 @@ fun HomeScreen( ) { val uiState : UiHomeModel by viewModel.uiState.collectAsState() val isLoading : Boolean by viewModel.isLoading.collectAsState() + val visibilityStates by viewModel.visibilityStates.collectAsState() val okStringResource = stringResource(id = android.R.string.ok) LaunchedEffect(uiState.showSnackbar) { @@ -99,15 +101,26 @@ fun HomeScreen( .weight(1f) .padding(bottom = uiState.fabAdHeight) ) { - items(items = uiState.carts , key = { cart -> cart.cartId }) { cart -> - CartItemComposable(cart , - onDelete = { viewModel.openDeleteCartDialog(it) } , - onCardClick = { - view.playSoundEffect(SoundEffectConstants.CLICK) - viewModel.openCart(cart) - } , - uiState = uiState , - modifier = Modifier.animateItem()) + itemsIndexed( + items = uiState.carts, + key = { _, cart -> cart.cartId } + ) { index, cart -> + val isVisible = visibilityStates.getOrElse(index) { false } + CartItemComposable( + cart = cart, + onDelete = { viewModel.openDeleteCartDialog(cart) }, + onCardClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) + viewModel.openCart(cart) + }, + uiState = uiState, + modifier = Modifier + .animateItem() + .animateVisibility( + visible = isVisible, + index = index + ) + ) } } } diff --git a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/home/HomeViewModel.kt index 246b09d..cd293e5 100644 --- a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/screens/home/HomeViewModel.kt @@ -6,6 +6,7 @@ import com.d4rk.cartcalculator.data.database.table.ShoppingCartTable import com.d4rk.cartcalculator.data.model.ui.screens.UiHomeModel import com.d4rk.cartcalculator.ui.screens.home.repository.HomeRepository import com.d4rk.cartcalculator.ui.viewmodel.BaseViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -30,6 +31,20 @@ class HomeViewModel(application : Application) : BaseViewModel(application) { } } hideLoading() + initializeVisibilityStates() + } + } + + private fun initializeVisibilityStates() { + viewModelScope.launch(coroutineExceptionHandler) { + delay(timeMillis = 50L) + _visibilityStates.value = List(_uiState.value.carts.size) { false } + _uiState.value.carts.indices.forEach { index -> + delay(timeMillis = index * 8L) + _visibilityStates.value = List(_visibilityStates.value.size) { lessonIndex -> + lessonIndex == index || _visibilityStates.value[lessonIndex] + } + } } } diff --git a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/viewmodel/BaseViewModel.kt b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/viewmodel/BaseViewModel.kt index 95a26fb..36ded39 100644 --- a/app/src/main/kotlin/com/d4rk/cartcalculator/ui/viewmodel/BaseViewModel.kt +++ b/app/src/main/kotlin/com/d4rk/cartcalculator/ui/viewmodel/BaseViewModel.kt @@ -4,14 +4,17 @@ import android.app.Application import android.content.ActivityNotFoundException import android.util.Log import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope import com.d4rk.cartcalculator.R import com.d4rk.cartcalculator.constants.error.ErrorType import com.d4rk.cartcalculator.data.model.ui.error.UiErrorModel import com.d4rk.cartcalculator.utils.error.ErrorHandler import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import java.io.FileNotFoundException import java.io.IOException @@ -27,6 +30,9 @@ open class BaseViewModel(application: Application) : AndroidViewModel(applicatio handleError(exception) } + val _visibilityStates = MutableStateFlow>(emptyList()) + val visibilityStates : StateFlow> = _visibilityStates.asStateFlow() + private fun handleError(exception: Throwable) { val errorType: ErrorType = when (exception) { is SecurityException -> ErrorType.SECURITY_EXCEPTION