diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e43a624..eba5ce2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.ksp) alias(libs.plugins.detekt) + alias(libs.plugins.kotlin.serialization) jacoco } @@ -134,8 +135,10 @@ dependencies { implementation(project(":data:product")) implementation(project(":core:designsystem")) implementation(project(":feature:catalog")) + implementation(project(":feature:detail")) implementation(libs.androidx.navigation.compose) implementation(libs.hilt.navigation.compose) + implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -170,7 +173,8 @@ tasks.register("jacocoTestReport") { ":domain:product:test", ":core:common:test", ":data:product:testDebugUnitTest", - ":feature:catalog:testDebugUnitTest" + ":feature:catalog:testDebugUnitTest", + ":feature:detail:testDebugUnitTest" ) reports { @@ -228,17 +232,32 @@ tasks.register("jacocoTestReport") { exclude(fileFilter) } + val detailTree = fileTree(project(":feature:detail").layout.buildDirectory.get()) { + include("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes/**/*.class") + exclude(fileFilter) + } + sourceDirectories.setFrom( files( "src/main/java", "${project(":domain:product").projectDir}/src/main/java", "${project(":core:common").projectDir}/src/main/java", "${project(":data:product").projectDir}/src/main/java", - "${project(":feature:catalog").projectDir}/src/main/java" + "${project(":feature:catalog").projectDir}/src/main/java", + "${project(":feature:detail").projectDir}/src/main/java" ) ) - classDirectories.setFrom(files(appTree, domainTree, coreCommonTree, dataTree, catalogTree)) + classDirectories.setFrom( + files( + appTree, + domainTree, + coreCommonTree, + dataTree, + catalogTree, + detailTree + ) + ) executionData.setFrom( files( @@ -256,6 +275,9 @@ tasks.register("jacocoTestReport") { }, fileTree(project(":feature:catalog").layout.buildDirectory.get()) { include("jacoco/testDebugUnitTest.exec") + }, + fileTree(project(":feature:detail").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") } ) ) @@ -307,6 +329,14 @@ tasks.register("jacocoTestCoverageVerification") { minimum = "0.75".toBigDecimal() } } + + rule { + element = "CLASS" + includes = listOf("com.example.catalogapp.feature.detail.*ViewModel*") + limit { + minimum = "0.75".toBigDecimal() + } + } } val fileFilter = listOf( @@ -360,17 +390,32 @@ tasks.register("jacocoTestCoverageVerification") { exclude(fileFilter) } + val detailTree = fileTree(project(":feature:detail").layout.buildDirectory.get()) { + include("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes/**/*.class") + exclude(fileFilter) + } + sourceDirectories.setFrom( files( "src/main/java", "${project(":domain:product").projectDir}/src/main/java", "${project(":core:common").projectDir}/src/main/java", "${project(":data:product").projectDir}/src/main/java", - "${project(":feature:catalog").projectDir}/src/main/java" + "${project(":feature:catalog").projectDir}/src/main/java", + "${project(":feature:detail").projectDir}/src/main/java" ) ) - classDirectories.setFrom(files(appTree, domainTree, coreCommonTree, dataTree, catalogTree)) + classDirectories.setFrom( + files( + appTree, + domainTree, + coreCommonTree, + dataTree, + catalogTree, + detailTree + ) + ) executionData.setFrom( files( @@ -388,6 +433,9 @@ tasks.register("jacocoTestCoverageVerification") { }, fileTree(project(":feature:catalog").layout.buildDirectory.get()) { include("jacoco/testDebugUnitTest.exec") + }, + fileTree(project(":feature:detail").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") } ) ) diff --git a/app/src/main/java/com/example/catalogapp/navigation/CatalogNavHost.kt b/app/src/main/java/com/example/catalogapp/navigation/CatalogNavHost.kt index 43279f9..34904bb 100644 --- a/app/src/main/java/com/example/catalogapp/navigation/CatalogNavHost.kt +++ b/app/src/main/java/com/example/catalogapp/navigation/CatalogNavHost.kt @@ -5,7 +5,9 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute import com.example.catalogapp.feature.catalog.CatalogScreen +import com.example.catalogapp.feature.detail.DetailScreen @Composable fun CatalogNavHost( @@ -13,19 +15,24 @@ fun CatalogNavHost( ) { NavHost( navController = navController, - startDestination = Screen.Catalog.route + startDestination = CatalogRoute ) { - composable(Screen.Catalog.route) { + composable { CatalogScreen( onNavigateToDetail = { productId -> - navController.navigate(Screen.Detail.createRoute(productId)) + navController.navigate(DetailRoute(productId)) } ) } - // Detail screen — added in next session - composable(Screen.Detail.route) { - // Placeholder until feature:detail is built + composable { backStackEntry -> + val route = backStackEntry.toRoute() + DetailScreen( + productId = route.productId, + onNavigateBack = { navController.popBackStack() } + ) } } } + + diff --git a/app/src/main/java/com/example/catalogapp/navigation/Routes.kt b/app/src/main/java/com/example/catalogapp/navigation/Routes.kt new file mode 100644 index 0000000..7f48ca8 --- /dev/null +++ b/app/src/main/java/com/example/catalogapp/navigation/Routes.kt @@ -0,0 +1,14 @@ +package com.example.catalogapp.navigation + +import kotlinx.serialization.Serializable + +// ─── Type-safe route definitions ────────────────────────────────────────────── +// Each route is a @Serializable class/object — Navigation generates +// the route string automatically, no manual string formatting needed. +// Compile-time safety: wrong argument types fail at build time, not runtime. + +@Serializable +object CatalogRoute + +@Serializable +data class DetailRoute(val productId: Int) diff --git a/app/src/main/java/com/example/catalogapp/navigation/Screen.kt b/app/src/main/java/com/example/catalogapp/navigation/Screen.kt deleted file mode 100644 index cd6e0be..0000000 --- a/app/src/main/java/com/example/catalogapp/navigation/Screen.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.catalogapp.navigation - -sealed class Screen(val route: String) { - object Catalog : Screen("catalog") - object Detail : Screen("detail/{productId}") { - fun createRoute(productId: Int) = "detail/$productId" - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 8c30188..ee19ddb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,4 +8,5 @@ plugins { alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.detekt) apply false + alias(libs.plugins.kotlin.serialization) apply false } diff --git a/feature/detail/.gitignore b/feature/detail/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/detail/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/detail/build.gradle.kts b/feature/detail/build.gradle.kts new file mode 100644 index 0000000..1e0d1f5 --- /dev/null +++ b/feature/detail/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) + alias(libs.plugins.detekt) + jacoco +} + +android { + namespace = "com.example.catalogapp.feature.detail" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +jacoco { + toolVersion = "0.8.12" +} + +detekt { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/java") +} + +dependencies { + implementation(project(":domain:product")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material.icons.extended) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.coil.compose) + + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/feature/detail/consumer-rules.pro b/feature/detail/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/detail/proguard-rules.pro b/feature/detail/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature/detail/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/detail/src/androidTest/java/com/example/catalogapp/feature/detail/ExampleInstrumentedTest.kt b/feature/detail/src/androidTest/java/com/example/catalogapp/feature/detail/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..85960a8 --- /dev/null +++ b/feature/detail/src/androidTest/java/com/example/catalogapp/feature/detail/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.catalogapp.feature.detail + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.catalogapp.feature.detail.test", appContext.packageName) + } +} diff --git a/feature/detail/src/main/AndroidManifest.xml b/feature/detail/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8bdb7e1 --- /dev/null +++ b/feature/detail/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailContract.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailContract.kt new file mode 100644 index 0000000..7a41ad3 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailContract.kt @@ -0,0 +1,33 @@ +package com.example.catalogapp.feature.detail + +import com.example.catalogapp.domain.product.Product + +// ─── UI State ───────────────────────────────────────────────────────────────── +data class DetailUiState( + val product: Product? = null, + val isLoading: Boolean = false, + val error: String? = null, + val isWishlisted: Boolean = false, + val isDescriptionExpanded: Boolean = false +) { + val showLoading: Boolean get() = isLoading && product == null + val showError: Boolean get() = error != null && !isLoading && product == null + val showContent: Boolean get() = product != null +} + +// ─── Intent ─────────────────────────────────────────────────────────────────── +sealed class DetailIntent { + data class LoadProduct(val productId: Int) : DetailIntent() + object NavigateBack : DetailIntent() + object ToggleWishlist : DetailIntent() + object AddToCart : DetailIntent() + object ToggleDescription : DetailIntent() + object ShareProduct : DetailIntent() +} + +// ─── Effect ─────────────────────────────────────────────────────────────────── +sealed class DetailEffect { + object NavigateBack : DetailEffect() + data class ShowSnackbar(val message: String) : DetailEffect() + data class ShareProduct(val title: String, val price: Double) : DetailEffect() +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailScreen.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailScreen.kt new file mode 100644 index 0000000..eee497d --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailScreen.kt @@ -0,0 +1,180 @@ +package com.example.catalogapp.feature.detail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.catalogapp.core.designsystem.components.CatalogErrorState +import com.example.catalogapp.core.designsystem.components.CatalogLoadingState +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.feature.detail.components.DetailBottomBar +import com.example.catalogapp.feature.detail.components.DetailContentCard +import com.example.catalogapp.feature.detail.components.DetailHeroImage +import com.example.catalogapp.feature.detail.components.DetailReviewsSection +import com.example.catalogapp.feature.detail.components.DetailTopBar + +@Composable +fun DetailScreen( + productId: Int, + onNavigateBack: () -> Unit, + viewModel: DetailViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(productId) { + viewModel.processIntent(DetailIntent.LoadProduct(productId)) + } + + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is DetailEffect.NavigateBack -> onNavigateBack() + is DetailEffect.ShowSnackbar -> + snackbarHostState.showSnackbar(effect.message) + + is DetailEffect.ShareProduct -> {} + } + } + } + + DetailContent( + uiState = uiState, + snackbarHostState = snackbarHostState, + onIntent = viewModel::processIntent + ) +} + +@Composable +internal fun DetailContent( + uiState: DetailUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + onIntent: (DetailIntent) -> Unit +) { + val scrollState = rememberScrollState() + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + if (uiState.showContent) { + DetailBottomBar( + isWishlisted = uiState.isWishlisted, + onWishlistClick = { onIntent(DetailIntent.ToggleWishlist) }, + onAddToCartClick = { onIntent(DetailIntent.AddToCart) } + ) + } + } + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize()) { + when { + uiState.showLoading -> CatalogLoadingState() + uiState.showError -> CatalogErrorState( + message = uiState.error ?: "Unknown error", + onRetry = { } + ) + + uiState.showContent -> { + val product = uiState.product!! + DetailBody( + product = product, + uiState = uiState, + onIntent = onIntent, + modifier = Modifier + .fillMaxSize() + .padding(bottom = paddingValues.calculateBottomPadding()) + .verticalScroll(scrollState) + ) + } + } + // Top bar always on top regardless of content state + DetailTopBar( + onBackClick = { onIntent(DetailIntent.NavigateBack) }, + onShareClick = { onIntent(DetailIntent.ShareProduct) } + ) + } + } +} + +@Composable +private fun DetailBody( + product: Product, + uiState: DetailUiState, + onIntent: (DetailIntent) -> Unit, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + androidx.compose.foundation.layout.Column(modifier = modifier) { + // Hero image — full bleed, 4:5 aspect ratio + DetailHeroImage( + imageUrl = product.imageUrl, + title = product.title + ) + // Content card overlapping hero by -mt-12 (48dp) + Box( + modifier = Modifier.padding(horizontal = spacing.md) + ) { + DetailContentCard( + product = product, + isDescriptionExpanded = uiState.isDescriptionExpanded, + onToggleDescription = { onIntent(DetailIntent.ToggleDescription) } + ) + } + // Reviews section — OUTSIDE the white card, in surface background + Box( + modifier = Modifier.padding( + top = spacing.md, + start = spacing.md, + end = spacing.md, + bottom = spacing.xl + ) + ) { + DetailReviewsSection() + } + } +} + +@Preview(showBackground = true, name = "Detail - Content") +@Composable +internal fun DetailContentPreview() { + CatalogTheme { + DetailContent( + uiState = DetailUiState(product = previewProduct), + onIntent = {} + ) + } +} + +@Preview(showBackground = true, name = "Detail - Loading") +@Composable +internal fun DetailLoadingPreview() { + CatalogTheme { + DetailContent( + uiState = DetailUiState(isLoading = true), + onIntent = {} + ) + } +} + +internal val previewProduct = Product( + id = 1, + title = "Titan Celestial Smartwatch - Midnight Edition", + price = 12999.0, + description = "Experience precision and luxury with the Celestial Midnight Edition. Features a brilliant 1.43\"" + + " AMOLED display with 1000 nits brightness.", + category = "electronics", + imageUrl = "", + rating = 4.8f +) diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailViewModel.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailViewModel.kt new file mode 100644 index 0000000..622378a --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailViewModel.kt @@ -0,0 +1,89 @@ +package com.example.catalogapp.feature.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.catalogapp.domain.product.ProductRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DetailViewModel @Inject constructor( + private val productRepository: ProductRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(DetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effect = Channel(Channel.BUFFERED) + val effect = _effect.receiveAsFlow() + + fun processIntent(intent: DetailIntent) { + when (intent) { + is DetailIntent.LoadProduct -> loadProduct(intent.productId) + is DetailIntent.NavigateBack -> navigateBack() + is DetailIntent.ToggleWishlist -> toggleWishlist() + is DetailIntent.AddToCart -> addToCart() + is DetailIntent.ToggleDescription -> toggleDescription() + is DetailIntent.ShareProduct -> shareProduct() + } + } + + private fun loadProduct(productId: Int) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + val product = productRepository.getProductById(productId) + if (product != null) { + _uiState.update { it.copy(isLoading = false, product = product) } + } else { + _uiState.update { + it.copy( + isLoading = false, + error = "Product not found" + ) + } + } + } + } + + private fun navigateBack() { + viewModelScope.launch { + _effect.send(DetailEffect.NavigateBack) + } + } + + private fun toggleWishlist() { + _uiState.update { it.copy(isWishlisted = !it.isWishlisted) } + viewModelScope.launch { + val message = if (_uiState.value.isWishlisted) { + "Added to wishlist" + } else { + "Removed from wishlist" + } + _effect.send(DetailEffect.ShowSnackbar(message)) + } + } + + private fun addToCart() { + viewModelScope.launch { + _effect.send(DetailEffect.ShowSnackbar("Added to cart")) + } + } + + private fun toggleDescription() { + _uiState.update { it.copy(isDescriptionExpanded = !it.isDescriptionExpanded) } + } + + private fun shareProduct() { + val product = _uiState.value.product ?: return + viewModelScope.launch { + _effect.send(DetailEffect.ShareProduct(product.title, product.price)) + } + } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/ProductFeatureProvider.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/ProductFeatureProvider.kt new file mode 100644 index 0000000..bfee12b --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/ProductFeatureProvider.kt @@ -0,0 +1,71 @@ +package com.example.catalogapp.feature.detail + +private const val FIRST_N_CHARACTERS_FROM_STRING = 8 + +data class ProductFeature(val icon: String, val label: String, val value: String) + +object ProductFeatureProvider { + + fun getFeaturesForProduct( + category: String, rating: Float + ): List { + val ratingFormatted = String.format(java.util.Locale.getDefault(), "%.1f", rating) + + return when { + category.contains("electronic", ignoreCase = true) || category.contains( + "phone", + ignoreCase = true + ) || category.contains("laptop", ignoreCase = true) -> listOf( + ProductFeature("◆", "Build", "Premium"), + ProductFeature("⚡", "Battery", "Long Life"), + ProductFeature("★", "Rating", ratingFormatted), + ProductFeature("✓", "Warranty", "1 Year") + ) + + category.contains("jewelery", ignoreCase = true) || category.contains( + "jewellery", + ignoreCase = true + ) || category.contains("jewelry", ignoreCase = true) -> listOf( + ProductFeature("◆", "Material", "Certified"), + ProductFeature("✦", "Purity", "Hallmark"), + ProductFeature("★", "Rating", ratingFormatted), + ProductFeature("✓", "Finish", "Polished") + ) + + category.contains("clothing", ignoreCase = true) || category.contains( + "fashion", + ignoreCase = true + ) || category.contains("wear", ignoreCase = true) -> listOf( + ProductFeature("◆", "Fabric", "Premium"), + ProductFeature("✦", "Fit", "Regular"), + ProductFeature("★", "Rating", ratingFormatted), + ProductFeature("✓", "Care", "Easy Wash") + ) + + category.contains("men", ignoreCase = true) -> listOf( + ProductFeature("◆", "Style", "Modern"), + ProductFeature("✦", "Fit", "Slim Fit"), + ProductFeature("★", "Rating", ratingFormatted), + ProductFeature("✓", "Care", "Easy Wash") + ) + + category.contains("women", ignoreCase = true) -> listOf( + ProductFeature("◆", "Style", "Trendy"), + ProductFeature("✦", "Fit", "Comfort"), + ProductFeature("★", "Rating", ratingFormatted), + ProductFeature("✓", "Care", "Easy Wash") + ) + + else -> listOf( + ProductFeature("◆", "Quality", "Premium"), + ProductFeature( + "✦", + "Type", + category.take(FIRST_N_CHARACTERS_FROM_STRING) + .replaceFirstChar { it.uppercase() }), + ProductFeature("★", "Rating", ratingFormatted), + ProductFeature("✓", "Delivery", "Fast") + ) + } + } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailBottomBar.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailBottomBar.kt new file mode 100644 index 0000000..26a7466 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailBottomBar.kt @@ -0,0 +1,127 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +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.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.ShoppingBag +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private const val BOTTOM_BAR_HEIGHT = 96 +private const val WISHLIST_BUTTON_SIZE = 56 +private const val WISHLIST_CORNER = 24 +private const val CART_BUTTON_HEIGHT = 56 +private const val CART_BUTTON_CORNER = 24 +private const val CART_ICON_SIZE = 24 + +@Composable +internal fun DetailBottomBar( + isWishlisted: Boolean, + onWishlistClick: () -> Unit, + onAddToCartClick: () -> Unit, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + Surface( + modifier = modifier + .fillMaxWidth() + .navigationBarsPadding(), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(BOTTOM_BAR_HEIGHT.dp) + .padding(horizontal = spacing.md, vertical = spacing.md), + horizontalArrangement = Arrangement.spacedBy(spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + WishlistButton(isWishlisted = isWishlisted, onClick = onWishlistClick) + AddToCartButton(onClick = onAddToCartClick, modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun WishlistButton(isWishlisted: Boolean, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.size(WISHLIST_BUTTON_SIZE.dp), + shape = RoundedCornerShape(WISHLIST_CORNER.dp), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary + ), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + imageVector = if (isWishlisted) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + contentDescription = if (isWishlisted) "Remove from wishlist" else "Add to wishlist", + tint = MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +private fun AddToCartButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + val spacing = CatalogTheme.spacing + Button( + onClick = onClick, + modifier = modifier.height(CART_BUTTON_HEIGHT.dp), + shape = RoundedCornerShape(CART_BUTTON_CORNER.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) { + Icon( + imageVector = Icons.Default.ShoppingBag, + contentDescription = null, + modifier = Modifier.size(CART_ICON_SIZE.dp) + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Text( + text = "Add to Cart", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailBottomBarPreview() { + CatalogTheme { + DetailBottomBar( + isWishlisted = false, + onWishlistClick = {}, + onAddToCartClick = {} + ) + } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailContentCard.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailContentCard.kt new file mode 100644 index 0000000..4de156a --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailContentCard.kt @@ -0,0 +1,73 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.feature.detail.previewProduct + +private const val CARD_CORNER = 24 +private const val CARD_OVERLAP_DP = 48 + +@Composable +internal fun DetailContentCard( + product: Product, + isDescriptionExpanded: Boolean, + onToggleDescription: () -> Unit, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + // Stitch: -mt-12 (48dp negative offset), rounded-3xl, surface-container-lowest, p-lg + Card( + modifier = modifier + .fillMaxWidth() + .offset(y = (-CARD_OVERLAP_DP).dp), + shape = RoundedCornerShape(CARD_CORNER.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Product header: brand, title, rating, availability + DetailProductHeader(product = product) + Spacer(modifier = Modifier.height(spacing.lg)) + // Price row + DetailPriceSection(price = product.price) + Spacer(modifier = Modifier.height(spacing.lg)) + // Features bento 2-column grid + DetailFeaturesGrid(product = product) + Spacer(modifier = Modifier.height(spacing.xl)) + // Collapsible description + DetailDescriptionSection( + description = product.description, + isExpanded = isDescriptionExpanded, + onToggle = onToggleDescription + ) + Spacer(modifier = Modifier.height(spacing.md)) + } + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailContentCardPreview() { + CatalogTheme { + DetailContentCard( + product = previewProduct, + isDescriptionExpanded = false, + onToggleDescription = {} + ) + } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailDescriptionSection.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailDescriptionSection.kt new file mode 100644 index 0000000..4fc05f1 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailDescriptionSection.kt @@ -0,0 +1,69 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.feature.detail.previewProduct + +private const val COLLAPSED_MAX_LINES = 3 + +@Composable +internal fun DetailDescriptionSection( + description: String, + isExpanded: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = spacing.lg) + ) { + // Stitch: font-title-lg text-title-lg + Text( + text = "Description", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(spacing.sm)) + // Stitch: text-on-surface-variant font-body-lg line-clamp-3 when collapsed + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = if (isExpanded) Int.MAX_VALUE else COLLAPSED_MAX_LINES, + overflow = if (isExpanded) TextOverflow.Visible else TextOverflow.Ellipsis + ) + // Stitch: text-primary font-label-lg "Read More / Read Less" + TextButton(onClick = onToggle) { + Text( + text = if (isExpanded) "Read Less" else "Read More", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailDescriptionPreview() { + CatalogTheme { + DetailDescriptionSection( + description = previewProduct.description, + isExpanded = false, + onToggle = {} + ) + } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailFeaturesGrid.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailFeaturesGrid.kt new file mode 100644 index 0000000..126f581 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailFeaturesGrid.kt @@ -0,0 +1,120 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.feature.detail.ProductFeature +import com.example.catalogapp.feature.detail.ProductFeatureProvider +import com.example.catalogapp.feature.detail.previewProduct + +private const val FEATURE_TILE_CORNER = 16 + +@Composable +internal fun DetailFeaturesGrid( + product: Product, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + val features = ProductFeatureProvider.getFeaturesForProduct( + category = product.category, + rating = product.rating + ) + + // Fixed 2x2 grid using two Rows — no LazyVerticalGrid needed for 4 static items + // LazyVerticalGrid inside verticalScroll causes unbounded height conflict + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = spacing.lg), + verticalArrangement = Arrangement.spacedBy(spacing.sm) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm) + ) { + FeatureTile( + feature = features[0], + modifier = Modifier.weight(1f) + ) + FeatureTile( + feature = features[1], + modifier = Modifier.weight(1f) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm) + ) { + FeatureTile( + feature = features[2], + modifier = Modifier.weight(1f) + ) + FeatureTile( + feature = features[3], + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun FeatureTile( + feature: ProductFeature, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + // Stitch: bg-surface-container-low p-md rounded-2xl border border-outline-variant/30 + Surface( + modifier = modifier.border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(FEATURE_TILE_CORNER.dp) + ), + shape = RoundedCornerShape(FEATURE_TILE_CORNER.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(spacing.xs) + ) { + Text( + text = feature.icon, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = feature.label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = feature.value, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailFeaturesGridPreview() { + CatalogTheme { DetailFeaturesGrid(product = previewProduct) } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailHeroImage.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailHeroImage.kt new file mode 100644 index 0000000..2678d72 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailHeroImage.kt @@ -0,0 +1,71 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private const val HERO_ASPECT_WIDTH = 4f +private const val HERO_ASPECT_HEIGHT = 5f +private const val GRADIENT_START_FRACTION = 0.5f + +@Composable +internal fun DetailHeroImage( + imageUrl: String, + title: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + // Stitch: aspect-[4/5] same as product card + .aspectRatio(HERO_ASPECT_WIDTH / HERO_ASPECT_HEIGHT) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + // Stitch: bg-gradient-to-t from-surface via-transparent to-transparent opacity-60 + // Gradient fades from surface color at bottom to transparent at top + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Transparent, + GRADIENT_START_FRACTION to Color.Transparent, + 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) + ) + ) + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailHeroImagePreview() { + CatalogTheme { + DetailHeroImage(imageUrl = "", title = "Preview Product") + } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailPriceSection.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailPriceSection.kt new file mode 100644 index 0000000..4867bcc --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailPriceSection.kt @@ -0,0 +1,65 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import java.util.Locale + +private const val DISCOUNT_PERCENT = 18 +private const val PERCENT_DIVISOR = 100.0 + +@Composable +internal fun DetailPriceSection( + price: Double, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + val originalPrice = price * (1 + DISCOUNT_PERCENT / PERCENT_DIVISOR) + + // Stitch: flex items-baseline gap-md mt-lg + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = spacing.lg), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(spacing.md) + ) { + // Stitch: text-headline-lg-mobile font-bold text-on-surface + Text( + text = "₹${String.format(Locale.getDefault(), "%,.0f", price)}", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + // Stitch: text-on-surface-variant line-through text-title-lg + Text( + text = "₹${String.format(Locale.getDefault(), "%,.0f", originalPrice)}", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textDecoration = TextDecoration.LineThrough + ) + // Stitch: text-secondary font-bold text-title-lg + Text( + text = "$DISCOUNT_PERCENT% OFF", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary + ) + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailPriceSectionPreview() { + CatalogTheme { DetailPriceSection(price = 12999.0) } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailProductHeader.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailProductHeader.kt new file mode 100644 index 0000000..73be1cf --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailProductHeader.kt @@ -0,0 +1,140 @@ +package com.example.catalogapp.feature.detail.components + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.compose.ui.unit.sp +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.feature.detail.previewProduct +import java.util.Locale + +private const val STAR_SIZE = 18 +private const val BADGE_DOT_SIZE = 8 +private const val EMERALD_LIGHT = 0xFFD1FAE5 +private const val EMERALD_DARK = 0xFF065F46 +private const val EMERALD_DOT = 0xFF059669 + +@Composable +internal fun DetailProductHeader( + product: Product, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + Column( + modifier = modifier.padding(spacing.lg), + verticalArrangement = Arrangement.spacedBy(spacing.sm) + ) { + // Stitch: text-primary font-label-lg tracking-widest uppercase + Text( + text = product.category.uppercase(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 3.sp, + fontWeight = FontWeight.Medium + ) + // Stitch: font-headline-lg-mobile = Hanken Grotesk 28sp bold + Text( + text = product.title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(spacing.xs)) + Row( + horizontalArrangement = Arrangement.spacedBy(spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + RatingPill(rating = product.rating) + InStockBadge() + } + } +} + +@Composable +private fun RatingPill(rating: Float) { + val spacing = CatalogTheme.spacing + // Stitch: bg-surface-container-high px-3 py-1 rounded-full + Row( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = CircleShape + ) + .padding(horizontal = 12.dp, vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xs) + ) { + // Stitch: filled amber star 18sp + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(STAR_SIZE.dp), + tint = MaterialTheme.colorScheme.secondary + ) + Text( + text = String.format(Locale.getDefault(), "%.1f", rating), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "(${(rating * 200).toInt()} reviews)", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun InStockBadge() { + val spacing = CatalogTheme.spacing + // Stitch: bg-emerald-100 text-emerald-800 px-3 py-1 rounded-full with pulse dot + Row( + modifier = Modifier + .background( + color = Color(EMERALD_LIGHT), + shape = CircleShape + ) + .padding(horizontal = 12.dp, vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xs) + ) { + Box( + modifier = Modifier + .size(BADGE_DOT_SIZE.dp) + .background(color = Color(EMERALD_DOT), shape = CircleShape) + ) + Text( + text = "In Stock", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = Color(EMERALD_DARK) + ) + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailProductHeaderPreview() { + CatalogTheme { DetailProductHeader(product = previewProduct) } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailReviewsSection.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailReviewsSection.kt new file mode 100644 index 0000000..a37b069 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailReviewsSection.kt @@ -0,0 +1,192 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private const val REVIEW_CARD_CORNER = 16 +private const val AVATAR_SIZE = 40 +private const val STAR_SIZE = 14 +private const val EMPTY_STAR_ALPHA = 0.2f +private const val TOTAL_STARS = 5 + +private data class ReviewData( + val name: String, + val rating: Int, + val review: String, + val date: String +) + +private val sampleReviews = listOf( + ReviewData( + name = "Rohan Sharma", + rating = 5, + review = "\"The build quality is absolutely phenomenal. " + + "It feels much more expensive than it is. " + + "Highly recommend for anyone looking for a premium product.\"", + date = "2 days ago" + ), + ReviewData( + name = "Ananya Verma", + rating = 4, + review = "\"Absolutely love the battery life! " + + "The software is fluid and the tracking seems very accurate " + + "compared to my other devices.\"", + date = "1 week ago" + ) +) + +@Composable +internal fun DetailReviewsSection(modifier: Modifier = Modifier) { + val spacing = CatalogTheme.spacing + Column(modifier = modifier.fillMaxWidth()) { + // Section header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Customer Reviews", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + TextButton(onClick = { }) { + Text( + text = "See All", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } + Spacer(modifier = Modifier.height(spacing.md)) + Column(verticalArrangement = Arrangement.spacedBy(spacing.md)) { + sampleReviews.forEach { review -> + ReviewCard(review = review) + } + } + } +} + +@Composable +private fun ReviewCard(review: ReviewData) { + val spacing = CatalogTheme.spacing + // Stitch: bg-surface-container p-lg rounded-2xl border border-outline-variant/20 shadow-sm + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(REVIEW_CARD_CORNER.dp) + ) + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = EMPTY_STAR_ALPHA), + shape = RoundedCornerShape(REVIEW_CARD_CORNER.dp) + ) + .padding(spacing.lg) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + ReviewerAvatar(name = review.name) + Column { + Text( + text = review.name, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + // Stitch: stars in text-secondary-container (amber) + StarRow(rating = review.rating) + } + } + Text( + text = review.date, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(spacing.md)) + Text( + text = review.review, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ReviewerAvatar(name: String) { + // Stitch: w-10 h-10 rounded-full — initials avatar since we don't have real user images + Box( + modifier = Modifier + .size(AVATAR_SIZE.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = name.first().toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } +} + +@Composable +private fun StarRow(rating: Int) { + Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) { + repeat(TOTAL_STARS) { index -> + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(STAR_SIZE.dp), + // Stitch: filled stars in secondary-container (amber), empty star muted + tint = if (index < rating) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EMPTY_STAR_ALPHA) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailReviewsSectionPreview() { + CatalogTheme { DetailReviewsSection() } +} diff --git a/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailTopBar.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailTopBar.kt new file mode 100644 index 0000000..56fe435 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailTopBar.kt @@ -0,0 +1,101 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private const val TOP_BAR_BUTTON_SIZE = 40 + +@Composable +internal fun DetailTopBar( + onBackClick: () -> Unit, + onShareClick: () -> Unit, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + // Stitch: fixed overlay, transparent, surface/20 circle buttons + Row( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TopBarButton( + onClick = onBackClick + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) { + TopBarButton(onClick = onShareClick) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + TopBarButton(onClick = { }) { + Icon( + imageVector = Icons.Default.ShoppingCart, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun TopBarButton( + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .size(TOP_BAR_BUTTON_SIZE.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), + shape = CircleShape + ) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + content() + } +} + +@Preview(showBackground = true) +@Composable +internal fun DetailTopBarPreview() { + CatalogTheme { + DetailTopBar(onBackClick = {}, onShareClick = {}) + } +} diff --git a/feature/detail/src/test/java/com/example/catalogapp/feature/detail/DetailViewModelTest.kt b/feature/detail/src/test/java/com/example/catalogapp/feature/detail/DetailViewModelTest.kt new file mode 100644 index 0000000..f57282d --- /dev/null +++ b/feature/detail/src/test/java/com/example/catalogapp/feature/detail/DetailViewModelTest.kt @@ -0,0 +1,144 @@ +package com.example.catalogapp.feature.detail + +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.domain.product.ProductRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DetailViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val productRepository: ProductRepository = mockk() + private lateinit var viewModel: DetailViewModel + + private val fakeProduct = Product( + id = 1, + title = "Test Product", + price = 999.0, + description = "Test description", + category = "electronics", + imageUrl = "https://example.com/image.png", + rating = 4.5f + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + viewModel = DetailViewModel(productRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state is correct`() = runTest { + val state = viewModel.uiState.value + assertNull(state.product) + assertFalse(state.isLoading) + assertNull(state.error) + assertFalse(state.isWishlisted) + assertFalse(state.isDescriptionExpanded) + } + + @Test + fun `LoadProduct sets loading then shows product on success`() = runTest { + coEvery { productRepository.getProductById(1) } returns fakeProduct + + viewModel.processIntent(DetailIntent.LoadProduct(1)) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertNotNull(state.product) + assertEquals("Test Product", state.product?.title) + assertTrue(state.showContent) + assertFalse(state.showLoading) + assertFalse(state.showError) + } + + @Test + fun `LoadProduct sets error when product not found`() = runTest { + coEvery { productRepository.getProductById(99) } returns null + + viewModel.processIntent(DetailIntent.LoadProduct(99)) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertNull(state.product) + assertEquals("Product not found", state.error) + assertTrue(state.showError) + } + + @Test + fun `ToggleWishlist toggles isWishlisted state`() = runTest { + coEvery { productRepository.getProductById(1) } returns fakeProduct + viewModel.processIntent(DetailIntent.LoadProduct(1)) + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isWishlisted) + viewModel.processIntent(DetailIntent.ToggleWishlist) + assertTrue(viewModel.uiState.value.isWishlisted) + viewModel.processIntent(DetailIntent.ToggleWishlist) + assertFalse(viewModel.uiState.value.isWishlisted) + } + + @Test + fun `ToggleDescription toggles isDescriptionExpanded state`() = runTest { + assertFalse(viewModel.uiState.value.isDescriptionExpanded) + viewModel.processIntent(DetailIntent.ToggleDescription) + assertTrue(viewModel.uiState.value.isDescriptionExpanded) + viewModel.processIntent(DetailIntent.ToggleDescription) + assertFalse(viewModel.uiState.value.isDescriptionExpanded) + } + + @Test + fun `NavigateBack sends NavigateBack effect`() = runTest { + viewModel.processIntent(DetailIntent.NavigateBack) + advanceUntilIdle() + + var receivedEffect: DetailEffect? = null + val job = launch { + viewModel.effect.collect { receivedEffect = it } + } + advanceUntilIdle() + job.cancel() + + assertTrue(receivedEffect is DetailEffect.NavigateBack) + } + + @Test + fun `AddToCart sends ShowSnackbar effect with correct message`() = runTest { + viewModel.processIntent(DetailIntent.AddToCart) + advanceUntilIdle() + + var receivedEffect: DetailEffect? = null + val job = launch { + viewModel.effect.collect { receivedEffect = it } + } + advanceUntilIdle() + job.cancel() + + assertTrue(receivedEffect is DetailEffect.ShowSnackbar) + assertEquals("Added to cart", (receivedEffect as DetailEffect.ShowSnackbar).message) + } +} diff --git a/feature/detail/src/test/java/com/example/catalogapp/feature/detail/ExampleUnitTest.kt b/feature/detail/src/test/java/com/example/catalogapp/feature/detail/ExampleUnitTest.kt new file mode 100644 index 0000000..20135a6 --- /dev/null +++ b/feature/detail/src/test/java/com/example/catalogapp/feature/detail/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.catalogapp.feature.detail + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab47f49..7572bf4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,8 @@ material = "1.14.0" hilt = "2.59.2" ksp = "2.3.6" detekt = "1.23.7" +kotlin-serialization = "2.2.10" +foundation = "1.11.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -59,6 +61,8 @@ lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle- coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.7.3" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -69,4 +73,5 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f265a30..697c0f5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,3 +30,4 @@ include(":domain:product") include(":data:product") include(":core:designsystem") include(":feature:catalog") +include(":feature:detail")