diff --git a/app/src/main/java/com/sa/commercex/MainActivity.kt b/app/src/main/java/com/sa/commercex/MainActivity.kt index 1d01e68..38c1018 100644 --- a/app/src/main/java/com/sa/commercex/MainActivity.kt +++ b/app/src/main/java/com/sa/commercex/MainActivity.kt @@ -4,6 +4,13 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -30,53 +37,49 @@ class MainActivity : ComponentActivity() { setContent { CommerceXTheme { - var showProductDetail by remember { mutableStateOf(false) } - var showCart by remember { mutableStateOf(false) } - var showSearch by remember { mutableStateOf(false) } - var showProfile by remember { mutableStateOf(false) } - var showLogin by remember { mutableStateOf(false) } + var currentScreen by remember { mutableStateOf(AppScreen.HOME) } Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - when { - showLogin -> { - LoginScreenMockup( - onSignInClick = { - showLogin = false - showProfile = true - } + AnimatedContent( + targetState = currentScreen, + transitionSpec = { + (slideInHorizontally( + initialOffsetX = { it / 3 }, + animationSpec = tween(260) + ) + fadeIn(animationSpec = tween(260))).togetherWith( + slideOutHorizontally( + targetOffsetX = { -it / 3 }, + animationSpec = tween(220) + ) + fadeOut(animationSpec = tween(220)) ) - } - showProfile -> { - ProfileScreenMockup( - onBackClick = { showProfile = false }, - onLogoutClick = { - showProfile = false - showLogin = true - } + }, + label = "main_screen_transition" + ) { screen -> + when (screen) { + AppScreen.LOGIN -> LoginScreenMockup( + onSignInClick = { currentScreen = AppScreen.PROFILE } ) - } - showSearch -> { - SearchScreenDefaultMockup() - } - showCart -> { - CartScreenMockup( - onBackClick = { showCart = false } + AppScreen.PROFILE -> ProfileScreenMockup( + onBackClick = { currentScreen = AppScreen.HOME }, + onLogoutClick = { currentScreen = AppScreen.LOGIN } ) - } - showProductDetail -> { - ProductDetailMockup( - onBackClick = { showProductDetail = false } + AppScreen.SEARCH -> SearchScreenDefaultMockup( + onBackClick = { currentScreen = AppScreen.HOME } ) - } - else -> { - HomeScreenMockup( - onProductClick = { showProductDetail = true }, - onCartClick = { showCart = true }, - onSearchClick = { showSearch = true }, - onProfileClick = { showProfile = true } + AppScreen.CART -> CartScreenMockup( + onBackClick = { currentScreen = AppScreen.HOME } + ) + AppScreen.PRODUCT_DETAIL -> ProductDetailMockup( + onBackClick = { currentScreen = AppScreen.HOME } + ) + AppScreen.HOME -> HomeScreenMockup( + onProductClick = { currentScreen = AppScreen.PRODUCT_DETAIL }, + onCartClick = { currentScreen = AppScreen.CART }, + onSearchClick = { currentScreen = AppScreen.SEARCH }, + onProfileClick = { currentScreen = AppScreen.PROFILE } ) } } @@ -85,3 +88,12 @@ class MainActivity : ComponentActivity() { } } } + +private enum class AppScreen { + HOME, + SEARCH, + CART, + PRODUCT_DETAIL, + PROFILE, + LOGIN +} diff --git a/core/feature/feature-cart/src/main/java/com/sa/feature/cart/ui/CartScreenMockup.kt b/core/feature/feature-cart/src/main/java/com/sa/feature/cart/ui/CartScreenMockup.kt index c0c18d1..33cde40 100644 --- a/core/feature/feature-cart/src/main/java/com/sa/feature/cart/ui/CartScreenMockup.kt +++ b/core/feature/feature-cart/src/main/java/com/sa/feature/cart/ui/CartScreenMockup.kt @@ -1,5 +1,10 @@ package com.sa.feature.cart.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -27,6 +32,7 @@ import com.sa.core.ui.component.OrderSummary import com.sa.core.ui.component.PrimaryButton import com.sa.core.ui.component.SimpleTopAppBar import com.sa.core.ui.theme.* +import kotlinx.coroutines.delay /** * Cart Screen Mockup @@ -64,12 +70,13 @@ fun CartScreenMockup( onBackClick: () -> Unit = {} ) { val cartItems = remember { - listOf( - CartMockItem("iPhone 9", 549.0, 1), - CartMockItem("MacBook Pro", 1749.0, 1), - CartMockItem("Brown Perfume", 40.0, 2) + mutableStateListOf( + CartMockItem(id = "1", title = "iPhone 9", price = 549.0, quantity = 1), + CartMockItem(id = "2", title = "MacBook Pro", price = 1749.0, quantity = 1), + CartMockItem(id = "3", title = "Brown Perfume", price = 40.0, quantity = 2) ) } + val removingItemIds = remember { mutableStateListOf() } Box( modifier = Modifier @@ -103,14 +110,31 @@ fun CartScreenMockup( Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { cartItems.forEach { item -> - CartItemCard( - imageUrl = "https://cdn.dummyjson.com/products/1/thumbnail.jpg", - title = item.title, - price = item.price, - quantity = item.quantity, - onQuantityChange = { /* Quantity change */ }, - onDeleteClick = { /* Delete */ } - ) + AnimatedVisibility( + visible = !removingItemIds.contains(item.id), + exit = slideOutHorizontally( + animationSpec = tween(240), + targetOffsetX = { -it } + ) + fadeOut(animationSpec = tween(220)) + shrinkVertically(animationSpec = tween(220)) + ) { + CartItemCard( + imageUrl = "https://cdn.dummyjson.com/products/1/thumbnail.jpg", + title = item.title, + price = item.price, + quantity = item.quantity, + onQuantityChange = { newQty -> + val index = cartItems.indexOfFirst { it.id == item.id } + if (index >= 0) { + cartItems[index] = cartItems[index].copy(quantity = newQty) + } + }, + onDeleteClick = { + if (!removingItemIds.contains(item.id)) { + removingItemIds.add(item.id) + } + } + ) + } } } } @@ -122,6 +146,15 @@ fun CartScreenMockup( .navigationBarsPadding() ) } + + LaunchedEffect(removingItemIds.size) { + if (removingItemIds.isNotEmpty()) { + val id = removingItemIds.first() + delay(240) + cartItems.removeAll { it.id == id } + removingItemIds.remove(id) + } + } } @Composable @@ -211,8 +244,8 @@ private fun CartSummaryBar( } private data class CartMockItem( + val id: String, val title: String, val price: Double, val quantity: Int ) - diff --git a/core/feature/feature-product/src/main/java/com/sa/feature/product/ui/HomeScreenMockup.kt b/core/feature/feature-product/src/main/java/com/sa/feature/product/ui/HomeScreenMockup.kt index 625a893..ba93038 100644 --- a/core/feature/feature-product/src/main/java/com/sa/feature/product/ui/HomeScreenMockup.kt +++ b/core/feature/feature-product/src/main/java/com/sa/feature/product/ui/HomeScreenMockup.kt @@ -1,16 +1,24 @@ package com.sa.feature.product.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.with import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material3.* @@ -18,12 +26,15 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.sa.core.ui.component.* import com.sa.core.ui.theme.* +import kotlinx.coroutines.delay /** * Home Screen Mockup - Light Mode @@ -44,6 +55,7 @@ fun HomeScreenLightPreview() { } @Composable +@OptIn(ExperimentalAnimationApi::class) fun HomeScreenMockup( onProductClick: () -> Unit = {}, onCartClick: () -> Unit = {}, @@ -53,22 +65,46 @@ fun HomeScreenMockup( var selectedCategory by remember { mutableStateOf("All") } var cartItemCount by remember { mutableStateOf(3) } var selectedNavItem by remember { mutableStateOf(BottomNavItem.HOME) } + var isRefreshing by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(true) } // Sample product data for mockup val products = remember { listOf( - MockProduct("iPhone 9", 549.0, 699.0, 12, 4.69, 94), - MockProduct("Samsung Universe", 1249.0, 1549.0, 19, 4.09, 36), - MockProduct("OPPOF19", 280.0, null, null, 4.3, 123), - MockProduct("Huawei P30", 499.0, 699.0, 28, 4.09, 258), - MockProduct("MacBook Pro", 1749.0, 1999.0, 12, 4.57, 70), - MockProduct("Samsung Galaxy", 899.0, null, null, 4.8, 145), - MockProduct("Perfume Oil", 13.0, null, null, 4.26, 65), - MockProduct("Brown Perfume", 40.0, 55.0, 27, 4.0, 52) + MockProduct("iPhone 9", 549.0, 699.0, 12, 4.69, 94, "Electronics"), + MockProduct("Samsung Universe", 1249.0, 1549.0, 19, 4.09, 36, "Electronics"), + MockProduct("OPPOF19", 280.0, null, null, 4.3, 123, "Electronics"), + MockProduct("Huawei P30", 499.0, 699.0, 28, 4.09, 258, "Electronics"), + MockProduct("MacBook Pro", 1749.0, 1999.0, 12, 4.57, 70, "Electronics"), + MockProduct("Samsung Galaxy", 899.0, null, null, 4.8, 145, "Electronics"), + MockProduct("Perfume Oil", 13.0, null, null, 4.26, 65, "Women's Clothing"), + MockProduct("Brown Perfume", 40.0, 55.0, 27, 4.0, 52, "Jewelry") ) } val categories = listOf("All", "Electronics", "Jewelry", "Men's Clothing", "Women's Clothing") + val filteredProducts = remember(selectedCategory, products) { + if (selectedCategory == "All") products else products.filter { it.category == selectedCategory } + } + + LaunchedEffect(Unit) { + delay(900) + isLoading = false + } + + val refreshProducts: () -> Unit = { + if (!isRefreshing) { + isRefreshing = true + } + } + + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(950) + cartItemCount += 1 + isRefreshing = false + } + } Box( modifier = Modifier @@ -82,8 +118,8 @@ fun HomeScreenMockup( // Glassmorphic Header GlassmorphicHeader( cartItemCount = cartItemCount, - onSearchClick = { /* Search action */ }, - onCartClick = { /* Cart action */ } + onSearchClick = onSearchClick, + onCartClick = onCartClick ) // Hero Banner @@ -97,14 +133,30 @@ fun HomeScreenMockup( onCategorySelected = { selectedCategory = it } ) + PullToRefreshStrip( + isRefreshing = isRefreshing, + onRefresh = refreshProducts + ) + Spacer(modifier = Modifier.height(Spacing.lg)) - // Product Grid with staggered animation - ProductGridWithAnimation( - products = products, - modifier = Modifier.weight(1f), - onProductClick = onProductClick - ) + Box(modifier = Modifier.weight(1f)) { + if (isLoading) { + ShimmerProductGrid(modifier = Modifier.fillMaxSize()) + } else { + AnimatedContent( + targetState = selectedCategory, + transitionSpec = { fadeIn(tween(180)) with fadeOut(tween(180)) }, + label = "category_layout_transition" + ) { _ -> + ProductGridWithAnimation( + products = filteredProducts, + modifier = Modifier.fillMaxSize(), + onProductClick = onProductClick + ) + } + } + } } // Fixed Bottom Navigation @@ -127,6 +179,77 @@ fun HomeScreenMockup( } } +@Composable +private fun PullToRefreshStrip( + isRefreshing: Boolean, + onRefresh: () -> Unit +) { + var dragDistance by remember { mutableStateOf(0f) } + val progress = (dragDistance / 150f).coerceIn(0f, 1f) + val arrowRotation by animateFloatAsState( + targetValue = progress * 180f, + animationSpec = tween(durationMillis = 120), + label = "pull_arrow_rotation" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = Spacing.sm) + .pointerInput(isRefreshing) { + detectVerticalDragGestures( + onVerticalDrag = { _, dragAmount -> + if (!isRefreshing) { + dragDistance = (dragDistance + dragAmount).coerceIn(0f, 180f) + } + }, + onDragEnd = { + if (!isRefreshing && progress > 0.85f) { + onRefresh() + } + dragDistance = 0f + }, + onDragCancel = { dragDistance = 0f } + ) + }, + contentAlignment = Alignment.Center + ) { + if (isRefreshing) { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Text( + text = "Refreshing products...", + style = MaterialTheme.typography.labelLarge, + color = TextSecondaryColor + ) + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (progress > 0.5f) Icons.Filled.Refresh else Icons.Filled.KeyboardArrowDown, + contentDescription = "Pull to refresh", + tint = PrimaryColor, + modifier = Modifier.graphicsLayer(rotationZ = arrowRotation) + ) + Text( + text = if (progress > 0.85f) "Release to refresh" else "Pull down to refresh", + style = MaterialTheme.typography.labelLarge, + color = TextSecondaryColor + ) + } + } + } +} + /** * Glassmorphic Header Component */ @@ -346,5 +469,6 @@ data class MockProduct( val originalPrice: Double?, val discountPercent: Int?, val rating: Double, - val reviewCount: Int + val reviewCount: Int, + val category: String ) diff --git a/core/feature/feature-search/src/main/java/com/sa/feature/search/ui/SearchScreenMockup.kt b/core/feature/feature-search/src/main/java/com/sa/feature/search/ui/SearchScreenMockup.kt index bc50f19..4b73436 100644 --- a/core/feature/feature-search/src/main/java/com/sa/feature/search/ui/SearchScreenMockup.kt +++ b/core/feature/feature-search/src/main/java/com/sa/feature/search/ui/SearchScreenMockup.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Search import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -72,7 +73,9 @@ fun SearchScreenNoResultsPreview() { } @Composable -fun SearchScreenDefaultMockup() { +fun SearchScreenDefaultMockup( + onBackClick: () -> Unit = {} +) { Box( modifier = Modifier .fillMaxSize() @@ -86,7 +89,7 @@ fun SearchScreenDefaultMockup() { .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Spacing.lg) ) { - SearchHeader() + SearchHeader(onBackClick = onBackClick) SearchBar( value = "", @@ -210,9 +213,11 @@ fun SearchScreenNoResultsMockup() { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SearchHeader() { +@OptIn(ExperimentalMaterial3Api::class) +private fun SearchHeader( + onBackClick: () -> Unit = {} +) { TopAppBar( title = { Text( @@ -222,9 +227,9 @@ private fun SearchHeader() { ) }, navigationIcon = { - IconButton(onClick = { /* Back action */ }) { + IconButton(onClick = onBackClick) { Icon( - imageVector = Icons.Filled.Search, + imageVector = Icons.Filled.ArrowBack, contentDescription = "Back", tint = TextPrimaryColor ) @@ -276,4 +281,3 @@ private data class MockSearchProduct( val rating: Double, val reviewCount: Int ) - diff --git a/core/ui/src/main/java/com/sa/core/ui/component/ChipsAndBadges.kt b/core/ui/src/main/java/com/sa/core/ui/component/ChipsAndBadges.kt index 4ccd12d..b165e6d 100644 --- a/core/ui/src/main/java/com/sa/core/ui/component/ChipsAndBadges.kt +++ b/core/ui/src/main/java/com/sa/core/ui/component/ChipsAndBadges.kt @@ -2,6 +2,7 @@ package com.sa.core.ui.component import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -16,6 +17,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -124,8 +126,25 @@ fun Badge( textColor: Color = Color.White ) { if (count > 0) { + var pulseTarget by remember { mutableStateOf(1f) } + val animatedScale by animateFloatAsState( + targetValue = pulseTarget, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "badge_scale" + ) + + LaunchedEffect(count) { + pulseTarget = 1.25f + kotlinx.coroutines.delay(120) + pulseTarget = 1f + } + Box( modifier = modifier + .scale(animatedScale) .size(20.dp) .background( color = backgroundColor, @@ -230,4 +249,3 @@ fun StockStatusBadge( ) } } - diff --git a/core/ui/src/main/java/com/sa/core/ui/component/Skeletons.kt b/core/ui/src/main/java/com/sa/core/ui/component/Skeletons.kt new file mode 100644 index 0000000..2125519 --- /dev/null +++ b/core/ui/src/main/java/com/sa/core/ui/component/Skeletons.kt @@ -0,0 +1,115 @@ +package com.sa.core.ui.component + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.sa.core.ui.theme.Spacing +import com.sa.core.ui.theme.SurfaceVariant + +@Composable +fun ShimmerProductGrid( + modifier: Modifier = Modifier, + itemCount: Int = 6 +) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg), + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.lg), + contentPadding = PaddingValues(bottom = 80.dp) + ) { + items((1..itemCount).toList()) { + ShimmerProductCard() + } + } +} + +@Composable +private fun ShimmerProductCard() { + val brush = rememberShimmerBrush() + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(12.dp)) + .background(brush) + ) + Box( + modifier = Modifier + .fillMaxWidth(0.9f) + .height(14.dp) + .clip(RoundedCornerShape(8.dp)) + .background(brush) + ) + Box( + modifier = Modifier + .fillMaxWidth(0.55f) + .height(14.dp) + .clip(RoundedCornerShape(8.dp)) + .background(brush) + ) + Box( + modifier = Modifier + .size(width = 72.dp, height = 18.dp) + .clip(RoundedCornerShape(8.dp)) + .background(brush) + ) + } +} + +@Composable +private fun rememberShimmerBrush(): Brush { + val transition = rememberInfiniteTransition(label = "shimmer_transition") + val animatedProgress = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1100, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_progress" + ) + + return Brush.linearGradient( + colors = listOf( + SurfaceVariant, + Color.White, + SurfaceVariant + ), + start = Offset(x = animatedProgress.value * 900f - 450f, y = 0f), + end = Offset(x = animatedProgress.value * 900f, y = 280f) + ) +}