diff --git a/ keystore.propertie b/ keystore.propertie new file mode 100644 index 0000000..1276c0e --- /dev/null +++ b/ keystore.propertie @@ -0,0 +1,4 @@ +storeFile=catalogapp-release.jks +storePassword=prajwal4121998 +keyAlias=catalogapp-key +keyPassword=prajwal4121998 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6334d4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,190 @@ +name: CatalogApp CI + +on: + push: + branches: + - main + - develop + - 'feature/**' + - 'hotfix/**' + pull_request: + branches: + - main + - develop + workflow_dispatch: + +env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dkotlin.incremental=false" + +jobs: + # ── Job 1: Quality Gates ────────────────────────────────────────────────── + lint-and-test: + name: Lint + Test + Quality Gates + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Cache Gradle build outputs + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.android/build-cache + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', '**/gradle-wrapper.properties', '**/libs.versions.toml') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Grant gradlew permission + run: chmod +x gradlew + + - name: Run Android Lint + run: ./gradlew lintDevDebug --stacktrace + + - name: Run unit tests + run: ./gradlew testDevDebugUnitTest --stacktrace + + - name: Generate JaCoCo coverage report + run: ./gradlew jacocoTestReport --stacktrace + + - name: Verify coverage threshold + run: ./gradlew jacocoTestCoverageVerification --stacktrace + + - name: Run Detekt + run: ./gradlew detekt --stacktrace + + - name: Upload lint report + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: app/build/reports/lint-results-devDebug.html + retention-days: 7 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: '**/build/reports/tests/' + retention-days: 7 + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-coverage-report + path: app/build/reports/jacoco/jacocoTestReport/html/ + retention-days: 7 + + - name: Upload Detekt report + if: always() + uses: actions/upload-artifact@v4 + with: + name: detekt-report + path: '**/build/reports/detekt/detekt.html' + retention-days: 7 + + # ── Job 2: Build ────────────────────────────────────────────────────────── + build: + name: Build APK / AAB + runs-on: ubuntu-latest + needs: lint-and-test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Cache Gradle build outputs + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.android/build-cache + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', '**/gradle-wrapper.properties', '**/libs.versions.toml') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Grant gradlew permission + run: chmod +x gradlew + + - name: Decode keystore + if: github.ref_name == 'main' || github.ref_name == 'develop' + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > ${{ github.workspace }}/catalogapp-release.jks + echo "KEYSTORE_FILE=${{ github.workspace }}/catalogapp-release.jks" >> $GITHUB_ENV + + - name: Set signing environment variables + if: github.ref_name == 'main' || github.ref_name == 'develop' + run: | + echo "KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> $GITHUB_ENV + echo "KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> $GITHUB_ENV + echo "KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> $GITHUB_ENV + + - name: Build Dev Debug APK (feature branches) + if: github.ref_name != 'develop' && github.ref_name != 'main' + run: ./gradlew assembleDevDebug --stacktrace + + - name: Build Staging Debug APK (develop) + if: github.ref_name == 'develop' + run: ./gradlew assembleStagingDebug --stacktrace + + - name: Build Prod Release AAB (main) + if: github.ref_name == 'main' + run: ./gradlew bundleProdRelease --stacktrace + + - name: Upload Dev APK + if: github.ref_name != 'develop' && github.ref_name != 'main' + uses: actions/upload-artifact@v4 + with: + name: dev-debug-apk-${{ github.run_number }} + path: app/build/outputs/apk/dev/debug/*.apk + retention-days: 7 + + - name: Upload Staging APK + if: github.ref_name == 'develop' + uses: actions/upload-artifact@v4 + with: + name: staging-debug-apk-${{ github.run_number }} + path: app/build/outputs/apk/staging/debug/*.apk + retention-days: 1 + + - name: Upload Prod AAB + if: github.ref_name == 'main' + uses: actions/upload-artifact@v4 + with: + name: prod-release-aab-${{ github.run_number }} + path: app/build/outputs/bundle/prodRelease/*.aab + retention-days: 1 + + - name: Clean up keystore + if: always() + run: rm -f ${{ github.workspace }}/catalogapp-release.jks + + # ── Job 3: Deploy ───────────────────────────────────────────────────────── + deploy: + name: Deploy (Module 6 - pending UI) + runs-on: ubuntu-latest + needs: build + if: github.ref_name == 'develop' || github.ref_name == 'main' + + steps: + - name: Deploy placeholder + run: echo "Firebase App Distribution configured in Module 6 after first UI screen" diff --git a/.gitignore b/.gitignore index ebece43..ac48044 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,45 @@ # Built outputs -/build -app/build -core/*/build -data/*/build -domain/*/build -feature/*/build - -# Gradle -.gradle -gradle/wrapper/gradle-wrapper.jar +*.apk +*.aab +*.ap_ +*.dex + +# Build folders +build/ +app/build/ +**/build/ + +# Gradle cache and wrapper +.gradle/ +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties + +# Local configuration local.properties +keystore.properties -# Android Studio / IntelliJ -.idea/ +# Android Studio IDE files *.iml -*.iws -*.ipr -captures/ -.externalNativeBuild/ -.cxx/ -*.DS_Store +.idea/ +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/caches +.idea/modules.xml +.DS_Store -# Keystore — NEVER commit this +# Signing files — NEVER commit *.jks *.keystore -# Google Services (if added later) -google-services.json +# Navigation editor temp files +.navigation/ + +# Captures +captures/ -# Secrets -secrets.properties +# Test results +/app/test-results/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1d82639..cf492bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,36 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.detekt) + alias(libs.plugins.kotlin.serialization) + jacoco +} + +// ─── Keystore properties ────────────────────────────────────────────────────── +// Reads from keystore.properties locally, falls back to env vars on CI +// keystore.properties is in .gitignore — never committed +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(keystorePropertiesFile.inputStream()) } +fun signingProp(envVar: String, propKey: String): String? = + System.getenv(envVar) ?: keystoreProperties.getProperty(propKey) + +val canSign = listOf( + signingProp("KEYSTORE_FILE", "storeFile"), + signingProp("KEYSTORE_PASSWORD", "storePassword"), + signingProp("KEY_ALIAS", "keyAlias"), + signingProp("KEY_PASSWORD", "keyPassword") +).all { it != null } +// ───────────────────────────────────────────────────────────────────────────── + + android { namespace = "com.example.catalogapp" compileSdk { @@ -17,15 +43,68 @@ android { applicationId = "com.example.catalogapp" minSdk = 24 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + + // versionCode auto-increments from CI run number + // Locally: always 1. On CI: run 1 → versionCode 1, run 47 → versionCode 47 + versionCode = System.getenv("GITHUB_RUN_NUMBER")?.toInt() ?: 1 + versionName = "1.0.${System.getenv("GITHUB_RUN_NUMBER") ?: "0"}" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + // ─── Signing configs ────────────────────────────────────────────────────── + signingConfigs { + if (canSign) { + create("release") { + storeFile = file(signingProp("KEYSTORE_FILE", "storeFile")!!) + storePassword = signingProp("KEYSTORE_PASSWORD", "storePassword")!! + keyAlias = signingProp("KEY_ALIAS", "keyAlias")!! + keyPassword = signingProp("KEY_PASSWORD", "keyPassword")!! + } + } + } + // ───────────────────────────────────────────────────────────────────────── + + // ─── Flavors ────────────────────────────────────────────────────────────── + flavorDimensions += "environment" + + productFlavors { + create("dev") { + dimension = "environment" + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + buildConfigField("String", "BASE_URL", "\"https://fakestoreapi.com/\"") + buildConfigField("Boolean", "ENABLE_LOGGING", "true") + } + create("staging") { + dimension = "environment" + applicationIdSuffix = ".staging" + versionNameSuffix = "-staging" + buildConfigField("String", "BASE_URL", "\"https://fakestoreapi.com/\"") + buildConfigField("Boolean", "ENABLE_LOGGING", "true") + } + create("prod") { + dimension = "environment" + // No suffix — this is the real app + buildConfigField("String", "BASE_URL", "\"https://fakestoreapi.com/\"") + buildConfigField("Boolean", "ENABLE_LOGGING", "false") + } + } + // ───────────────────────────────────────────────────────────────────────── + buildTypes { - release { + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" isMinifyEnabled = false + isDebuggable = true + enableUnitTestCoverage = true + } + release { + isMinifyEnabled = true + isShrinkResources = true + isDebuggable = false + signingConfig = if (canSign) signingConfigs.findByName("release") else null proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -38,6 +117,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -52,6 +132,17 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.hilt.android) ksp(libs.hilt.compiler) + implementation(project(":data:product")) + implementation(project(":core:designsystem")) + implementation(project(":feature:catalog")) + implementation(project(":feature:detail")) + implementation(project(":feature:search")) + implementation(libs.androidx.navigation.compose) + implementation(libs.hilt.navigation.compose) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.hilt.work) + ksp(libs.hilt.work.compiler) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -60,3 +151,342 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) } + +// ─── JaCoCo configuration ───────────────────────────────────────────────────── +jacoco { + toolVersion = "0.8.12" +} + +// Enable coverage data collection during unit tests +tasks.withType { + configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} + +// ─── JaCoCo report task ─────────────────────────────────────────────────────── +tasks.register("jacocoTestReport") { + dependsOn( + "testDevDebugUnitTest", + ":domain:product:test", + ":core:common:test", + ":data:product:testDebugUnitTest", + ":feature:catalog:testDebugUnitTest", + ":feature:detail:testDebugUnitTest", + ":feature:search:testDebugUnitTest" + ) + + reports { + xml.required.set(true) + html.required.set(true) + } + + val fileFilter = listOf( + "**/R.class", "**/R\$*.class", "**/BuildConfig.*", + "**/Manifest*.*", "**/*Test*.*", "android/**/*.*", + "**/*Hilt*.*", "**/*Dagger*.*", "**/*_Factory*.*", + "**/*MembersInjector*.*", "**/di/**", + "**/hilt_aggregated_deps/**", + "**/dagger/hilt/**", + "**/*Database*.*", + "**/*Database_Impl*.*", + // Exclude Kotlin compiler-generated lambda/coroutine synthetic classes + "**/*\$*\$*.*", + "**/*\$inlined*.*", + // Compose UI — requires instrumented tests, not JUnit + // Covered separately via androidTest ComposeTestRule + "**/ui/**", + "**/*Screen*.*", + "**/*Content*.*", + "**/*composable*.*", + // Feature module components — pure Compose, no unit testable logic + "**/components/**" + ) + + val appTree = fileTree(layout.buildDirectory.get()) { + include( + "tmp/kotlin-classes/devDebug/**/*.class", + "intermediates/javac/devDebug/**/*.class" + ) + exclude(fileFilter) + } + + val domainTree = fileTree(project(":domain:product").layout.buildDirectory.get()) { + include("classes/kotlin/main/**/*.class") + exclude(fileFilter) + } + + val coreCommonTree = fileTree(project(":core:common").layout.buildDirectory.get()) { + include("classes/kotlin/main/**/*.class") + exclude(fileFilter) + } + + val dataTree = fileTree(project(":data:product").layout.buildDirectory.get()) { + include("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes/**/*.class") + exclude(fileFilter) + } + + val catalogTree = fileTree(project(":feature:catalog").layout.buildDirectory.get()) { + include("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes/**/*.class") + exclude(fileFilter) + } + + val detailTree = fileTree(project(":feature:detail").layout.buildDirectory.get()) { + include("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes/**/*.class") + exclude(fileFilter) + } + + val searchTree = fileTree(project(":feature:search").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:detail").projectDir}/src/main/java", + "${project(":feature:search").projectDir}/src/main/java" + ) + ) + + classDirectories.setFrom( + files( + appTree, + domainTree, + coreCommonTree, + dataTree, + catalogTree, + detailTree, + searchTree + ) + ) + + executionData.setFrom( + files( + fileTree(layout.buildDirectory.get()) { + include("outputs/unit_test_code_coverage/devDebugUnitTest/testDevDebugUnitTest.exec") + }, + fileTree(project(":domain:product").layout.buildDirectory.get()) { + include("jacoco/test.exec") + }, + fileTree(project(":core:common").layout.buildDirectory.get()) { + include("jacoco/test.exec") + }, + fileTree(project(":data:product").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") + }, + fileTree(project(":feature:catalog").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") + }, + fileTree(project(":feature:detail").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") + }, + fileTree(project(":feature:search").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") + } + ) + ) +} + +// ─── Coverage threshold enforcement ─────────────────────────────────────────── +tasks.register("jacocoTestCoverageVerification") { + dependsOn("jacocoTestReport") + + violationRules { + // Overall app threshold — excludes generated/untestable code via fileFilter + rule { + limit { + minimum = "0.55".toBigDecimal() + } + } + + // Domain layer — pure business logic, highest bar + rule { + element = "CLASS" + includes = listOf("com.example.catalogapp.domain.*") + limit { + minimum = "0.70".toBigDecimal() + } + } + + // Repository — critical class, must test error paths + rule { + element = "CLASS" + includes = listOf("com.example.catalogapp.data.product.repository.*") + limit { + minimum = "0.70".toBigDecimal() + } + } + + // Mapper — simple pure functions, high bar achievable + rule { + element = "CLASS" + includes = listOf("com.example.catalogapp.data.product.mapper.*") + limit { + minimum = "0.85".toBigDecimal() + } + } + + rule { + element = "CLASS" + includes = listOf("com.example.catalogapp.feature.catalog.*ViewModel*") + limit { + minimum = "0.75".toBigDecimal() + } + } + + rule { + element = "CLASS" + includes = listOf("com.example.catalogapp.feature.detail.*ViewModel*") + limit { + minimum = "0.75".toBigDecimal() + } + } + + rule { + element = "CLASS" + includes = listOf("com.example.catalogapp.feature.search.*ViewModel*") + limit { + minimum = "0.75".toBigDecimal() + } + } + } + + val fileFilter = listOf( + "**/R.class", "**/R\$*.class", "**/BuildConfig.*", + "**/Manifest*.*", "**/*Test*.*", "android/**/*.*", + "**/*Hilt*.*", "**/*Dagger*.*", "**/*_Factory*.*", + "**/hilt_aggregated_deps/**", + "**/dagger/hilt/**", + "**/dao/**", + "**/*Database*.*", + "**/*Database_Impl*.*", + // Exclude Kotlin compiler-generated lambda/coroutine synthetic classes + "**/*\$*\$*.*", + "**/*\$inlined*.*", + // Compose UI — requires instrumented tests, not JUnit + // Covered separately via androidTest ComposeTestRule + "**/ui/**", + "**/*Screen*.*", + "**/*Content*.*", + "**/*composable*.*", + // Feature module components — pure Compose, no unit testable logic + "**/components/**" + ) + + // Collect classes from all modules + val appTree = fileTree(layout.buildDirectory.get()) { + include( + "tmp/kotlin-classes/devDebug/**/*.class", + "intermediates/javac/devDebug/**/*.class" + ) + exclude(fileFilter) + } + + val domainTree = fileTree(project(":domain:product").layout.buildDirectory.get()) { + include("classes/kotlin/main/**/*.class") + exclude(fileFilter) + } + + val coreCommonTree = fileTree(project(":core:common").layout.buildDirectory.get()) { + include("classes/kotlin/main/**/*.class") + exclude(fileFilter) + } + + val dataTree = fileTree(project(":data:product").layout.buildDirectory.get()) { + include("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes/**/*.class") + exclude(fileFilter) + } + + val catalogTree = fileTree(project(":feature:catalog").layout.buildDirectory.get()) { + include("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes/**/*.class") + exclude(fileFilter) + } + + val detailTree = fileTree(project(":feature:detail").layout.buildDirectory.get()) { + include("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes/**/*.class") + exclude(fileFilter) + } + + val searchTree = fileTree(project(":feature:search").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:detail").projectDir}/src/main/java", + "${project(":feature:search").projectDir}/src/main/java" + ) + ) + + classDirectories.setFrom( + files( + appTree, + domainTree, + coreCommonTree, + dataTree, + catalogTree, + detailTree, + searchTree + ) + ) + + executionData.setFrom( + files( + fileTree(layout.buildDirectory.get()) { + include("outputs/unit_test_code_coverage/devDebugUnitTest/testDevDebugUnitTest.exec") + }, + fileTree(project(":domain:product").layout.buildDirectory.get()) { + include("jacoco/test.exec") + }, + fileTree(project(":core:common").layout.buildDirectory.get()) { + include("jacoco/test.exec") + }, + fileTree(project(":data:product").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") + }, + fileTree(project(":feature:catalog").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") + }, + fileTree(project(":feature:detail").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") + }, + fileTree(project(":feature:search").layout.buildDirectory.get()) { + include("jacoco/testDebugUnitTest.exec") + } + ) + ) +} + +// ─── Detekt configuration ────────────────────────────────────────────────── +detekt { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + autoCorrect = false + source.setFrom("src/main/java", "src/main/kotlin") +} + +tasks.withType().configureEach { + reports { + html.required.set(true) + xml.required.set(true) + txt.required.set(false) + sarif.required.set(false) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a405a76..fc63dcd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ @@ -23,6 +22,16 @@ + + + diff --git a/app/src/main/java/com/example/catalogapp/CatalogApplication.kt b/app/src/main/java/com/example/catalogapp/CatalogApplication.kt index 89275aa..7a0be34 100644 --- a/app/src/main/java/com/example/catalogapp/CatalogApplication.kt +++ b/app/src/main/java/com/example/catalogapp/CatalogApplication.kt @@ -1,7 +1,28 @@ package com.example.catalogapp import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import com.example.catalogapp.data.product.sync.SyncScheduler import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp -class CatalogApplication : Application() +class CatalogApplication : Application(), Configuration.Provider { + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + @Inject + lateinit var syncScheduler: SyncScheduler + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + override fun onCreate() { + super.onCreate() + syncScheduler.schedulePeriodicSync() + } +} diff --git a/app/src/main/java/com/example/catalogapp/MainActivity.kt b/app/src/main/java/com/example/catalogapp/MainActivity.kt index 2153785..eb923ba 100644 --- a/app/src/main/java/com/example/catalogapp/MainActivity.kt +++ b/app/src/main/java/com/example/catalogapp/MainActivity.kt @@ -4,14 +4,8 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.example.catalogapp.ui.theme.CatalogAppTheme +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.navigation.CatalogNavHost import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -21,30 +15,10 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - CatalogAppTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + CatalogTheme { +// Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> } + CatalogNavHost() } } } } - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - CatalogAppTheme { - Greeting("Android") - } -} diff --git a/app/src/main/java/com/example/catalogapp/navigation/CatalogNavHost.kt b/app/src/main/java/com/example/catalogapp/navigation/CatalogNavHost.kt new file mode 100644 index 0000000..ad15f1a --- /dev/null +++ b/app/src/main/java/com/example/catalogapp/navigation/CatalogNavHost.kt @@ -0,0 +1,84 @@ +package com.example.catalogapp.navigation + +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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 +import com.example.catalogapp.feature.search.SearchScreen + +private const val NAV_TRANSITION_DURATION_MS = 250 + +@Composable +fun CatalogNavHost( + navController: NavHostController = rememberNavController() +) { + Surface( + modifier = Modifier, + color = MaterialTheme.colorScheme.background + ) { + NavHost( + navController = navController, + startDestination = CatalogRoute, + enterTransition = { + slideInHorizontally( + animationSpec = tween(NAV_TRANSITION_DURATION_MS) + ) { fullWidth -> fullWidth } + fadeIn(tween(NAV_TRANSITION_DURATION_MS)) + }, + exitTransition = { + slideOutHorizontally( + animationSpec = tween(NAV_TRANSITION_DURATION_MS) + ) { fullWidth -> -fullWidth } + fadeOut(tween(NAV_TRANSITION_DURATION_MS)) + }, + popEnterTransition = { + slideInHorizontally( + animationSpec = tween(NAV_TRANSITION_DURATION_MS) + ) { fullWidth -> -fullWidth } + fadeIn(tween(NAV_TRANSITION_DURATION_MS)) + }, + popExitTransition = { + slideOutHorizontally( + animationSpec = tween(NAV_TRANSITION_DURATION_MS) + ) { fullWidth -> fullWidth } + fadeOut(tween(NAV_TRANSITION_DURATION_MS)) + } + ) { + composable { + CatalogScreen( + onNavigateToDetail = { productId -> + navController.navigateSafely(DetailRoute(productId)) + }, + onNavigateToSearch = { + navController.navigateSafely(SearchRoute) + } + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + DetailScreen( + productId = route.productId, + onNavigateBack = { navController.popBackStackSafely() } + ) + } + + composable { + SearchScreen( + onNavigateToDetail = { productId -> + navController.navigateSafely(DetailRoute(productId)) + } + ) + } + } + } +} + 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..23ab35d --- /dev/null +++ b/app/src/main/java/com/example/catalogapp/navigation/Routes.kt @@ -0,0 +1,17 @@ +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) + +@Serializable +object SearchRoute diff --git a/app/src/main/java/com/example/catalogapp/navigation/SafeNavigate.kt b/app/src/main/java/com/example/catalogapp/navigation/SafeNavigate.kt new file mode 100644 index 0000000..a80054a --- /dev/null +++ b/app/src/main/java/com/example/catalogapp/navigation/SafeNavigate.kt @@ -0,0 +1,24 @@ +package com.example.catalogapp.navigation + +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavController + +/** + * Prevents duplicate navigation calls from rapid double-taps or multiple + * simultaneous triggers (e.g. two different buttons both calling navigate() + * before the first transition settles). Only allows navigation when the + * current back stack entry is RESUMED — i.e. no transition is in flight. + */ +fun NavController.navigateSafely(route: Any) { + if (currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED) { + navigate(route) + } +} + +fun NavController.popBackStackSafely(): Boolean { + return if (currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED) { + popBackStack() + } else { + false + } +} diff --git a/app/src/main/java/com/example/catalogapp/ui/theme/Color.kt b/app/src/main/java/com/example/catalogapp/ui/theme/Color.kt index f371e00..7e27feb 100644 --- a/app/src/main/java/com/example/catalogapp/ui/theme/Color.kt +++ b/app/src/main/java/com/example/catalogapp/ui/theme/Color.kt @@ -8,4 +8,4 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) diff --git a/app/src/main/java/com/example/catalogapp/ui/theme/Theme.kt b/app/src/main/java/com/example/catalogapp/ui/theme/Theme.kt index 26382d8..201b822 100644 --- a/app/src/main/java/com/example/catalogapp/ui/theme/Theme.kt +++ b/app/src/main/java/com/example/catalogapp/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package com.example.catalogapp.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/java/com/example/catalogapp/ui/theme/Type.kt b/app/src/main/java/com/example/catalogapp/ui/theme/Type.kt index 464645f..c28624b 100644 --- a/app/src/main/java/com/example/catalogapp/ui/theme/Type.kt +++ b/app/src/main/java/com/example/catalogapp/ui/theme/Type.kt @@ -31,4 +31,4 @@ val Typography = Typography( letterSpacing = 0.5.sp ) */ -) \ No newline at end of file +) diff --git a/app/src/main/res/color/catalog_background.xml b/app/src/main/res/color/catalog_background.xml new file mode 100644 index 0000000..a8b409b --- /dev/null +++ b/app/src/main/res/color/catalog_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 63730c1..ee19ddb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,6 @@ plugins { alias(libs.plugins.kotlin.compose) apply false 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/catalogapp-release b/catalogapp-release new file mode 100644 index 0000000..c0e5c52 Binary files /dev/null and b/catalogapp-release differ diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..9346b9b --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,82 @@ +build: + maxIssues: 0 + excludeCorrectable: false + +complexity: + active: true + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 7 + CyclomaticComplexMethod: + active: true + threshold: 15 + TooManyFunctions: + active: true + thresholdInClasses: 20 + LargeClass: + active: true + threshold: 600 + +naming: + active: true + FunctionNaming: + active: true + # Allow PascalCase for Composable functions - this is the correct Compose convention + excludes: [ '**/ui/theme/**' ] + functionPattern: '([a-z][a-zA-Z0-9]*)|(^[A-Z][a-zA-Z0-9]*$)' + ClassNaming: + active: true + VariableNaming: + active: true + +style: + active: true + MagicNumber: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/ui/theme/**' + - '**/designsystem/theme/**' + - '**/designsystem/components/**' + ignoreNumbers: [ '-1', '0', '1', '2' ] + ignorePropertyDeclaration: true + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreNamedArgument: true + ignoreEnums: true + ignoreHashCodeFunction: true + MaxLineLength: + active: true + maxLineLength: 120 + WildcardImport: + active: true + UnusedImports: + active: true + ReturnCount: + active: true + max: 3 + NewLineAtEndOfFile: + active: true + +exceptions: + active: true + TooGenericExceptionCaught: + active: true + exceptionNames: + - 'Exception' + - 'Throwable' + - 'RuntimeException' + SwallowedException: + active: true + +potential-bugs: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 0de80e5..c27d84e 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -1,5 +1,17 @@ plugins { alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.detekt) +} + +jacoco { + toolVersion = "0.8.12" +} + +detekt { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/java") } dependencies { diff --git a/core/designsystem/.gitignore b/core/designsystem/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/designsystem/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts new file mode 100644 index 0000000..ffc190a --- /dev/null +++ b/core/designsystem/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.detekt) +} + +android { + namespace = "com.example.catalogapp.core.designsystem" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +detekt { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/java") +} + +dependencies { + 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.google.fonts) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.coil.compose) + implementation(libs.androidx.compose.material.icons.extended) +} diff --git a/core/designsystem/consumer-rules.pro b/core/designsystem/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/core/designsystem/proguard-rules.pro b/core/designsystem/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/core/designsystem/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/core/designsystem/src/androidTest/java/com/example/catalogapp/core/designsystem/ExampleInstrumentedTest.kt b/core/designsystem/src/androidTest/java/com/example/catalogapp/core/designsystem/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0d3861e --- /dev/null +++ b/core/designsystem/src/androidTest/java/com/example/catalogapp/core/designsystem/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.catalogapp.core.designsystem + +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.core.designsystem.test", appContext.packageName) + } +} diff --git a/core/designsystem/src/main/AndroidManifest.xml b/core/designsystem/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8bdb7e1 --- /dev/null +++ b/core/designsystem/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/CatalogAsyncImage.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/CatalogAsyncImage.kt new file mode 100644 index 0000000..f456174 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/CatalogAsyncImage.kt @@ -0,0 +1,75 @@ +package com.example.catalogapp.core.designsystem.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.AsyncImage +import coil.request.ImageRequest + +private const val IMAGE_CROSSFADE_DURATION_MS = 300 + +data class CatalogImagePlaceholders( + val placeholder: Painter, + val errorBackground: Painter +) { + companion object { + @Composable + fun default(): CatalogImagePlaceholders { + val tonal = ColorPainter(MaterialTheme.colorScheme.surfaceContainerHigh) + return CatalogImagePlaceholders(placeholder = tonal, errorBackground = tonal) + } + } +} + +@Composable +fun CatalogAsyncImage( + imageUrl: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + placeholders: CatalogImagePlaceholders = CatalogImagePlaceholders.default() +) { + var isError by remember(imageUrl) { mutableStateOf(false) } + + Box(modifier = modifier.fillMaxSize()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(IMAGE_CROSSFADE_DURATION_MS) + .build(), + contentDescription = contentDescription, + placeholder = placeholders.placeholder, + error = placeholders.errorBackground, + onError = { isError = true }, + onSuccess = { isError = false }, + contentScale = contentScale, + modifier = Modifier.fillMaxSize(), + ) + + if (isError) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .then(Modifier), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } +} + diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/CatalogComponents.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/CatalogComponents.kt new file mode 100644 index 0000000..577d580 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/CatalogComponents.kt @@ -0,0 +1,122 @@ +package com.example.catalogapp.core.designsystem.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private const val RETRY_BUTTON_WIDTH_FRACTION = 0.6f +private const val DEBOUNCE_INTERVAL_MS = 500L + +@Composable +fun CatalogLoadingState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + } +} + +@Composable +fun CatalogErrorState( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + + Column( + modifier = modifier + .fillMaxSize() + .padding(spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Something went wrong", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(spacing.lg)) + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth(RETRY_BUTTON_WIDTH_FRACTION) + ) { + Text(text = "Retry") + } + } +} + +@Composable +fun CatalogEmptyState( + message: String, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + + Column( + modifier = modifier + .fillMaxSize() + .padding(spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Nothing here yet", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun rememberDebouncedOnClick(onClick: () -> Unit): () -> Unit { + var lastClickTimeMs by remember { mutableLongStateOf(0L) } + return { + val now = System.currentTimeMillis() + if (now - lastClickTimeMs >= DEBOUNCE_INTERVAL_MS) { + lastClickTimeMs = now + onClick() + } + } +} diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/DetailHeroImageDefaults.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/DetailHeroImageDefaults.kt new file mode 100644 index 0000000..ea35bde --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/DetailHeroImageDefaults.kt @@ -0,0 +1,7 @@ +package com.example.catalogapp.core.designsystem.components + +object DetailHeroImageDefaults { + const val HERO_ASPECT_WIDTH = 4f + const val HERO_ASPECT_HEIGHT = 5f + const val GRADIENT_START_FRACTION = 0.5f +} diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/ProductCardDefaults.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/ProductCardDefaults.kt new file mode 100644 index 0000000..c68d1ee --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/ProductCardDefaults.kt @@ -0,0 +1,12 @@ +package com.example.catalogapp.core.designsystem.components + +import androidx.compose.ui.unit.dp + +// Promoted from feature:catalog into core:designsystem so BOTH +// ProductCard (real) and ProductCardSkeleton (loading) reference the same constants. +// This is the single source of truth for product card dimensions. +object ProductCardDefaults { + const val IMAGE_ASPECT_WIDTH = 4f + const val IMAGE_ASPECT_HEIGHT = 5f + val CORNER_RADIUS = 12.dp +} diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/SkeletonComponents.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/SkeletonComponents.kt new file mode 100644 index 0000000..f3d4ee7 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/components/SkeletonComponents.kt @@ -0,0 +1,281 @@ +package com.example.catalogapp.core.designsystem.components + +import androidx.compose.animation.core.LinearEasing +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.DetailHeroImageDefaults.HERO_ASPECT_HEIGHT +import com.example.catalogapp.core.designsystem.components.DetailHeroImageDefaults.HERO_ASPECT_WIDTH +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private const val SHIMMER_ANIMATION_DURATION_MS = 1200 +private const val SHIMMER_TRANSLATE_TARGET = 1000f +private val ChipCornerRadius = 12.dp +private val ChipSkeletonWidthDp = 72.dp +private val ChipSkeletonHeightDp = 32.dp +private val ChipSkeletonWidths = listOf(56.dp, 88.dp, 72.dp, 64.dp) +private val DetailSkeletonCardCorner = 24.dp +private val DetailSkeletonFeatureGridHeight = 96.dp +private const val CARD_OVERLAP_DP = 48 + +/** + * Shared shimmer brush used by every skeleton in the design system. + * Colors intentionally come from MaterialTheme so light/dark mode both work correctly. + */ +@Composable +internal fun rememberShimmerBrush(): Brush { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.surfaceContainerHigh + ) + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnimation by transition.animateFloat( + initialValue = 0f, + targetValue = SHIMMER_TRANSLATE_TARGET, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = SHIMMER_ANIMATION_DURATION_MS, + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_translate" + ) + return Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnimation - SHIMMER_TRANSLATE_TARGET, 0f), + end = Offset(translateAnimation, SHIMMER_TRANSLATE_TARGET) + ) +} + +@Composable +private fun ShimmerBox(modifier: Modifier = Modifier) { + Column(modifier = modifier.background(rememberShimmerBrush())) {} +} + +@Composable +fun CategoryChipSkeleton(modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(ChipCornerRadius), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier + .width(ChipSkeletonWidthDp) + .height(ChipSkeletonHeightDp) + .background(rememberShimmerBrush()) + ) {} + } +} + +@Composable +fun CategoryChipRowSkeleton(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(CatalogTheme.spacing.sm) + ) { + ChipSkeletonWidths.forEach { width -> + Surface( + shape = RoundedCornerShape(ChipCornerRadius), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier + .width(width) + .height(ChipSkeletonHeightDp) + .background(rememberShimmerBrush()) + ) { + CategoryChipSkeleton() + } + } + } + } +} + +/** + * Mirrors ProductCard's exact dimensions: square image area, two-line title, + * price bar. Used in ProductGridSkeleton while catalog data loads. + */ +@Composable +fun ProductCardSkeleton(modifier: Modifier = Modifier) { + val spacing = CatalogTheme.spacing + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(ProductCardDefaults.CORNER_RADIUS), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column { + // Image area — SAME aspect ratio constants as ProductImageSection + ShimmerBox( + modifier = Modifier + .fillMaxWidth() + .aspectRatio( + ProductCardDefaults.IMAGE_ASPECT_WIDTH / + ProductCardDefaults.IMAGE_ASPECT_HEIGHT + ) + ) + // Info area — SAME padding/spacing tokens as ProductInfoSection + Column( + modifier = Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.xs) + ) { + // Title — 2 lines, matches maxLines = 2 on the real title + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.9f) + .height(spacing.md) + ) + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(spacing.md) + ) + // Rating row placeholder — matches RatingRow's height + ShimmerBox( + modifier = Modifier + .width(60.dp) + .height(spacing.sm) + ) + // Price — matches titleMedium text block + ShimmerBox( + modifier = Modifier + .width(70.dp) + .height(spacing.md) + ) + } + } + } +} + +/** + * Mirrors the detail screen's content card sections below the hero image: + * title, price, and a features-grid placeholder. The hero image itself + * is NOT shimmered here — Coil's placeholder() handles that separately. + */ +@Composable +fun DetailSkeleton(modifier: Modifier = Modifier) { + val spacing = CatalogTheme.spacing + Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(HERO_ASPECT_WIDTH / HERO_ASPECT_HEIGHT) + .background(rememberShimmerBrush()) + ) {} + + Box(modifier = Modifier.padding(horizontal = spacing.md)) { + DetailSkeletonContentCard() + } + } +} + +@Composable +private fun DetailSkeletonContentCard() { + val spacing = CatalogTheme.spacing + Card( + modifier = Modifier + .fillMaxWidth() + .offset(y = -CARD_OVERLAP_DP.dp), + shape = RoundedCornerShape(DetailSkeletonCardCorner), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(spacing.md)) { + DetailSkeletonHeader() + Spacer(modifier = Modifier.height(spacing.lg)) + DetailSkeletonPriceAndFeatures() + Spacer(modifier = Modifier.height(spacing.xl)) + DetailSkeletonDescriptionLines() + } + } +} + +@Composable +private fun DetailSkeletonHeader() { + val spacing = CatalogTheme.spacing + Column( + modifier = Modifier + .fillMaxWidth(0.7f) + .height(spacing.lg) + .background(rememberShimmerBrush()) + ) {} + Spacer(modifier = Modifier.height(spacing.sm)) + Column( + modifier = Modifier + .width(100.dp) + .height(spacing.md) + .background(rememberShimmerBrush()) + ) {} +} + +@Composable +private fun DetailSkeletonPriceAndFeatures() { + val spacing = CatalogTheme.spacing + Column( + modifier = Modifier + .width(80.dp) + .height(spacing.lg) + .background(rememberShimmerBrush()) + ) {} + Spacer(modifier = Modifier.height(spacing.lg)) + Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) { + repeat(2) { + Column( + modifier = Modifier + .weight(1f) + .height(DetailSkeletonFeatureGridHeight) + .background(rememberShimmerBrush()) + ) {} + } + } +} + +@Composable +private fun DetailSkeletonDescriptionLines() { + val spacing = CatalogTheme.spacing + Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) { + repeat(3) { index -> + val widthFraction = if (index == 2) 0.5f else 1f + Column( + modifier = Modifier + .fillMaxWidth(widthFraction) + .height(spacing.sm) + .background(rememberShimmerBrush()) + ) {} + } + } +} diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/preview/CatalogAsyncImagePreview.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/preview/CatalogAsyncImagePreview.kt new file mode 100644 index 0000000..868e702 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/preview/CatalogAsyncImagePreview.kt @@ -0,0 +1,69 @@ +package com.example.catalogapp.core.designsystem.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +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 + +// This mirrors CatalogAsyncImage's PLACEHOLDER state visually — +// can't preview the real Coil pipeline since no network call runs in @Preview. +@Composable +private fun PlaceholderStatePreview() { + Box( + modifier = Modifier + .size(100.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + ) +} + +// Mirrors CatalogAsyncImage's ERROR state visually — tonal box + muted icon overlay +@Composable +private fun ErrorStatePreview() { + Box( + modifier = Modifier.size(100.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(100.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + ) + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } +} + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", showBackground = true, uiMode = 0x20) +@Composable +internal fun CatalogAsyncImageStatesPreview() { + CatalogTheme { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp) + ) { + Column { + PlaceholderStatePreview() + } + Column { + ErrorStatePreview() + } + } + } +} + diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/preview/ThemePreview.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/preview/ThemePreview.kt new file mode 100644 index 0000000..ee54808 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/preview/ThemePreview.kt @@ -0,0 +1,307 @@ +package com.example.catalogapp.core.designsystem.preview + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +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.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.CatalogEmptyState +import com.example.catalogapp.core.designsystem.components.CatalogErrorState +import com.example.catalogapp.core.designsystem.components.CatalogLoadingState +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +// ─── Color palette preview ──────────────────────────────────────────────────── +@Preview(showBackground = true, name = "Color Palette - Light") +@Preview( + showBackground = true, + name = "Color Palette - Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +internal fun ColorPalettePreview() { + CatalogTheme { + Surface { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Color Palette", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + ColorRow( + "Primary", + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.onPrimary + ) + ColorRow( + "Primary Container", + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer + ) + ColorRow( + "Secondary", + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.onSecondary + ) + ColorRow( + "Secondary Container", + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer + ) + ColorRow( + "Surface", + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.onSurface + ) + ColorRow( + "Background", + MaterialTheme.colorScheme.background, + MaterialTheme.colorScheme.onBackground + ) + ColorRow( + "Error", + MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.onError + ) + } + } + } +} + +@Composable +internal fun ColorRow(name: String, color: Color, onColor: Color) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .size(width = 120.dp, height = 48.dp) + .background(color, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Text(text = name, color = onColor, style = MaterialTheme.typography.labelSmall) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +// ─── Typography preview ─────────────────────────────────────────────────────── +@Preview(showBackground = true, name = "Typography - Light") +@Preview( + showBackground = true, + name = "Typography - Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +internal fun TypographyPreview() { + CatalogTheme { + Surface { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Headline Large", style = MaterialTheme.typography.headlineLarge) + Text("Headline Medium", style = MaterialTheme.typography.headlineMedium) + Text("Title Large", style = MaterialTheme.typography.titleLarge) + Text("Title Medium", style = MaterialTheme.typography.titleMedium) + Text("Title Small", style = MaterialTheme.typography.titleSmall) + Text("Body Large", style = MaterialTheme.typography.bodyLarge) + Text("Body Medium", style = MaterialTheme.typography.bodyMedium) + Text("Body Small", style = MaterialTheme.typography.bodySmall) + Text("Label Large", style = MaterialTheme.typography.labelLarge) + Text("Label Medium", style = MaterialTheme.typography.labelMedium) + Text("Label Small", style = MaterialTheme.typography.labelSmall) + } + } + } +} + +// ─── Buttons and chips preview ──────────────────────────────────────────────── +@Preview(showBackground = true, name = "Buttons - Light") +@Preview( + showBackground = true, + name = "Buttons - Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +internal fun ButtonPreview() { + CatalogTheme { + Surface { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Buttons", style = MaterialTheme.typography.titleLarge) + Button(onClick = {}, modifier = Modifier.fillMaxWidth()) { + Text("Primary Button") + } + OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { + Text("Outlined Button") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = true, onClick = {}, label = { Text("Active") }) + FilterChip(selected = false, onClick = {}, label = { Text("Inactive") }) + } + } + } + } +} + +// ─── Product card preview ───────────────────────────────────────────────────── +@Preview(showBackground = true, name = "Product Card - Light") +@Preview( + showBackground = true, + name = "Product Card - Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +internal fun ProductCardPreview() { + CatalogTheme { + val spacing = CatalogTheme.spacing + Surface { + Column(modifier = Modifier.padding(spacing.md)) { + Text( + text = "Product Card", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = spacing.sm) + ) + Card( + modifier = Modifier.width(180.dp), + shape = MaterialTheme.shapes.large, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text( + text = "Product Image", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column(modifier = Modifier.padding(spacing.sm)) { + Text( + text = "Fjallraven Backpack", + style = MaterialTheme.typography.titleSmall, + maxLines = 2 + ) + Text( + text = "₹8,999", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "⭐ 3.9 (120)", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } +} + +// ─── Spacing preview ────────────────────────────────────────────────────────── +@Preview(showBackground = true, name = "Spacing Scale") +@Composable +internal fun SpacingPreview() { + CatalogTheme { + val spacing = CatalogTheme.spacing + Surface { + Column( + modifier = Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.sm) + ) { + Text("Spacing Scale", style = MaterialTheme.typography.titleLarge) + SpacingRow("xs = 4dp", spacing.xs) + SpacingRow("sm = 8dp", spacing.sm) + SpacingRow("md = 16dp", spacing.md) + SpacingRow("lg = 24dp", spacing.lg) + SpacingRow("xl = 32dp", spacing.xl) + } + } + } +} + +@Composable +internal fun SpacingRow(label: String, size: Dp) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.width(100.dp) + ) + Box( + modifier = Modifier + .width(size * 2) + .height(24.dp) + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(4.dp)) + ) + } +} + +// ─── State components preview ───────────────────────────────────────────────── +@Preview(showBackground = true, name = "Loading State", heightDp = 300) +@Composable +internal fun LoadingPreview() { + CatalogTheme { + Surface { CatalogLoadingState() } + } +} + +@Preview(showBackground = true, name = "Error State", heightDp = 400) +@Composable +internal fun ErrorPreview() { + CatalogTheme { + Surface { + CatalogErrorState( + message = "Unable to load products. Please check your connection.", + onRetry = {} + ) + } + } +} + +@Preview(showBackground = true, name = "Empty State", heightDp = 400) +@Composable +internal fun EmptyPreview() { + CatalogTheme { + Surface { + CatalogEmptyState(message = "No products found in this category.") + } + } +} diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Color.kt new file mode 100644 index 0000000..c368260 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Color.kt @@ -0,0 +1,89 @@ +package com.example.catalogapp.core.designsystem.theme + +import androidx.compose.ui.graphics.Color + +// ─── Primary — Indigo/Violet ────────────────────────────────────────────────── +val Primary = Color(0xFF3525CD) +val OnPrimary = Color(0xFFFFFFFF) +val PrimaryContainer = Color(0xFF4F46E5) +val OnPrimaryContainer = Color(0xFFDAD7FF) +val InversePrimary = Color(0xFFC3C0FF) + +// ─── Secondary — Amber/Gold ─────────────────────────────────────────────────── +val Secondary = Color(0xFF855300) +val OnSecondary = Color(0xFFFFFFFF) +val SecondaryContainer = Color(0xFFFEA619) +val OnSecondaryContainer = Color(0xFF684000) + +// ─── Tertiary — Violet ──────────────────────────────────────────────────────── +val Tertiary = Color(0xFF5C01C9) +val OnTertiary = Color(0xFFFFFFFF) +val TertiaryContainer = Color(0xFF7534E1) +val OnTertiaryContainer = Color(0xFFE4D4FF) + +// ─── Error ──────────────────────────────────────────────────────────────────── +val Error = Color(0xFFBA1A1A) +val OnError = Color(0xFFFFFFFF) +val ErrorContainer = Color(0xFFFFDAD6) +val OnErrorContainer = Color(0xFF93000A) + +// ─── Surface & Background ───────────────────────────────────────────────────── +val Background = Color(0xFFF8F9FA) +val OnBackground = Color(0xFF191C1D) +val Surface = Color(0xFFF8F9FA) +val OnSurface = Color(0xFF191C1D) +val SurfaceVariant = Color(0xFFE1E3E4) +val OnSurfaceVariant = Color(0xFF464555) +val SurfaceTint = Color( + 0xFF4D44E3) +val InverseSurface = Color(0xFF2E3132) +val InverseOnSurface = Color(0xFFF0F1F2) + +// ─── Surface containers ─────────────────────────────────────────────────────── +val SurfaceContainerLowest = Color(0xFFFFFFFF) +val SurfaceContainerLow = Color(0xFFF3F4F5) +val SurfaceContainer = Color(0xFFEDEEEF) +val SurfaceContainerHigh = Color(0xFFE7E8E9) +val SurfaceContainerHighest = Color(0xFFE1E3E4) + +// ─── Outline ────────────────────────────────────────────────────────────────── +val Outline = Color(0xFF777587) +val OutlineVariant = Color(0xFFC7C4D8) + +// ─── Dark mode colors ───────────────────────────────────────────────────────── +val PrimaryDark = Color(0xFFC3C0FF) +val OnPrimaryDark = Color(0xFF1A0E8F) +val PrimaryContainerDark = Color(0xFF2B1DB5) +val OnPrimaryContainerDark = Color(0xFFDAD7FF) + +val SecondaryDark = Color(0xFFFFB95F) +val OnSecondaryDark = Color(0xFF462A00) +val SecondaryContainerDark = Color(0xFF643D00) +val OnSecondaryContainerDark = Color(0xFFFFDDB8) + +val TertiaryDark = Color(0xFFD3BBFF) +val OnTertiaryDark = Color(0xFF3F0090) +val TertiaryContainerDark = Color(0xFF5800AC) +val OnTertiaryContainerDark = Color(0xFFEBDDFF) + +val BackgroundDark = Color(0xFF111315) +val OnBackgroundDark = Color(0xFFE1E3E4) +val SurfaceDark = Color(0xFF111315) +val OnSurfaceDark = Color(0xFFE1E3E4) +val SurfaceVariantDark = Color(0xFF464555) +val OnSurfaceVariantDark = Color(0xFFC7C4D8) +val InverseSurfaceDark = Color(0xFFE1E3E4) +val InverseOnSurfaceDark = Color(0xFF2E3132) + +val SurfaceContainerLowestDark = Color(0xFF0C0E0F) +val SurfaceContainerLowDark = Color(0xFF191C1D) +val SurfaceContainerDark = Color(0xFF1D2021) +val SurfaceContainerHighDark = Color(0xFF272B2C) +val SurfaceContainerHighestDark = Color(0xFF323537) + +val OutlineDark = Color(0xFF918FA2) +val OutlineVariantDark = Color(0xFF464555) +val ErrorDark = Color(0xFFFFB4AB) +val OnErrorDark = Color(0xFF690005) +val ErrorContainerDark = Color(0xFF93000A) +val OnErrorContainerDark = Color(0xFFFFDAD6) diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Shape.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Shape.kt new file mode 100644 index 0000000..028bbdc --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Shape.kt @@ -0,0 +1,18 @@ +package com.example.catalogapp.core.designsystem.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val CatalogShapes = Shapes( + // extraSmall — 4dp (Stitch sm) + extraSmall = RoundedCornerShape(4.dp), + // small — 8dp (Stitch DEFAULT) + small = RoundedCornerShape(8.dp), + // medium — 12dp (Stitch md) — chips + medium = RoundedCornerShape(12.dp), + // large — 16dp (Stitch lg) — cards + large = RoundedCornerShape(16.dp), + // extraLarge — 24dp (Stitch xl) — buttons, search bars + extraLarge = RoundedCornerShape(24.dp) +) diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Spacing.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Spacing.kt new file mode 100644 index 0000000..7e4aa2e --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Spacing.kt @@ -0,0 +1,19 @@ +package com.example.catalogapp.core.designsystem.theme + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class Spacing( + val xs: Dp = 4.dp, // Stitch xs + val sm: Dp = 8.dp, // Stitch sm / base + val md: Dp = 16.dp, // Stitch md / mobile margin + val lg: Dp = 24.dp, // Stitch lg / tablet margin + val xl: Dp = 32.dp, // Stitch xl — section separation + val xxl: Dp = 48.dp, // extra large gaps + val cardPadding: Dp = 12.dp, + val screenPadding: Dp = 16.dp, + val gutter: Dp = 16.dp +) + +val LocalSpacing = compositionLocalOf { Spacing() } diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Theme.kt new file mode 100644 index 0000000..dfc2257 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Theme.kt @@ -0,0 +1,122 @@ +package com.example.catalogapp.core.designsystem.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +val Scrim = Color(0xFF000000) + +// ─── Light color scheme — from Stitch design.md ─────────────────────────────── +private val LightColorScheme = lightColorScheme( + primary = Primary, + onPrimary = OnPrimary, + primaryContainer = PrimaryContainer, + onPrimaryContainer = OnPrimaryContainer, + inversePrimary = InversePrimary, + secondary = Secondary, + onSecondary = OnSecondary, + secondaryContainer = SecondaryContainer, + onSecondaryContainer = OnSecondaryContainer, + tertiary = Tertiary, + onTertiary = OnTertiary, + tertiaryContainer = TertiaryContainer, + onTertiaryContainer = OnTertiaryContainer, + error = Error, + onError = OnError, + errorContainer = ErrorContainer, + onErrorContainer = OnErrorContainer, + background = Background, + onBackground = OnBackground, + surface = Surface, + onSurface = OnSurface, + onSurfaceVariant = OnSurfaceVariant, + surfaceVariant = SurfaceVariant, + surfaceTint = SurfaceTint, + inverseSurface = InverseSurface, + inverseOnSurface = InverseOnSurface, + outline = Outline, + outlineVariant = OutlineVariant, + scrim = Scrim, + surfaceContainerLow = SurfaceContainerLow, + surfaceContainerLowest = SurfaceContainerLowest, + surfaceContainerHigh = SurfaceContainerHigh, +) + +// ─── Dark color scheme ──────────────────────────────────────────────────────── +private val DarkColorScheme = darkColorScheme( + primary = PrimaryDark, + onPrimary = OnPrimaryDark, + primaryContainer = PrimaryContainerDark, + onPrimaryContainer = OnPrimaryContainerDark, + secondary = SecondaryDark, + onSecondary = OnSecondaryDark, + secondaryContainer = SecondaryContainerDark, + onSecondaryContainer = OnSecondaryContainerDark, + tertiary = TertiaryDark, + onTertiary = OnTertiaryDark, + tertiaryContainer = TertiaryContainerDark, + onTertiaryContainer = OnTertiaryContainerDark, + error = ErrorDark, + onError = OnErrorDark, + errorContainer = ErrorContainerDark, + onErrorContainer = OnErrorContainerDark, + background = BackgroundDark, + onBackground = OnBackgroundDark, + surface = SurfaceDark, + onSurface = OnSurfaceDark, + surfaceVariant = SurfaceVariantDark, + onSurfaceVariant = OnSurfaceVariantDark, + inverseSurface = InverseSurfaceDark, + inverseOnSurface = InverseOnSurfaceDark, + outline = OutlineDark, + outlineVariant = OutlineVariantDark, + surfaceContainerLow = SurfaceContainerLowDark, + surfaceContainerLowest = SurfaceContainerLowestDark, + surfaceContainerHigh = SurfaceContainerHighDark, +) + +@Composable +fun CatalogTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color — Android 12+ (Material You) + // Uses device wallpaper colors if true, falls back to our Stitch palette + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) + else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + CompositionLocalProvider( + LocalSpacing provides Spacing() + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = CatalogTypography, + shapes = CatalogShapes, + content = content + ) + } +} + +// ─── Convenience extension — access spacing from anywhere in the theme ───────── +object CatalogTheme { + val spacing: Spacing + @Composable + get() = LocalSpacing.current +} diff --git a/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Type.kt b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Type.kt new file mode 100644 index 0000000..63817ce --- /dev/null +++ b/core/designsystem/src/main/java/com/example/catalogapp/core/designsystem/theme/Type.kt @@ -0,0 +1,176 @@ +package com.example.catalogapp.core.designsystem.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import androidx.compose.ui.unit.sp +import com.example.catalogapp.core.designsystem.R + +// ─── Google Fonts provider ──────────────────────────────────────────────────── +private val provider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs +) + +// ─── Hanken Grotesk — headlines and titles ──────────────────────────────────── +private val HankenGroteskFont = GoogleFont("Hanken Grotesk") + +private val HankenGrotesk = FontFamily( + Font( + googleFont = HankenGroteskFont, + fontProvider = provider, + weight = FontWeight.Normal + ), + Font( + googleFont = HankenGroteskFont, + fontProvider = provider, + weight = FontWeight.Medium + ), + Font( + googleFont = HankenGroteskFont, + fontProvider = provider, + weight = FontWeight.SemiBold + ), + Font( + googleFont = HankenGroteskFont, + fontProvider = provider, + weight = FontWeight.Bold + ) +) + +// ─── Inter — body text and labels ───────────────────────────────────────────── +private val InterFont = GoogleFont("Inter") + +private val Inter = FontFamily( + Font( + googleFont = InterFont, + fontProvider = provider, + weight = FontWeight.Normal + ), + Font( + googleFont = InterFont, + fontProvider = provider, + weight = FontWeight.Medium + ), + Font( + googleFont = InterFont, + fontProvider = provider, + weight = FontWeight.SemiBold + ) +) + +// ─── Typography scale — mapped from Stitch design.md ───────────────────────── +val CatalogTypography = Typography( + // Display + displayLarge = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp + ), + displaySmall = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 44.sp + ), + + // Headline — Stitch headline-lg maps here + headlineLarge = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp + ), + headlineMedium = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineSmall = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp + ), + + // Title — Stitch title-lg maps here + titleLarge = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp + ), + titleMedium = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = HankenGrotesk, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + + // Body — Inter for readability + bodyLarge = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + + // Label + labelLarge = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/core/designsystem/src/main/res/drawable/ic_image_error.xml b/core/designsystem/src/main/res/drawable/ic_image_error.xml new file mode 100644 index 0000000..7608122 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_image_error.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/core/designsystem/src/main/res/drawable/ic_product_placeholder.xml b/core/designsystem/src/main/res/drawable/ic_product_placeholder.xml new file mode 100644 index 0000000..a9f204a --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_product_placeholder.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/core/designsystem/src/main/res/values/font_certs.xml b/core/designsystem/src/main/res/values/font_certs.xml new file mode 100644 index 0000000..e9f0ed9 --- /dev/null +++ b/core/designsystem/src/main/res/values/font_certs.xml @@ -0,0 +1,19 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzETMBEGA1UEChMKR29vZ2xlIEluYzEXMBVGA1UECxMOQ2xvdWQgUGxhdGZvcm0xGjAYBgNVBAMTEW1ldGFkYXRhLmdvb2dsZS5jb20wHhcNMTYwMzE0MDAwMDAwWhcNMjkxMjMxMjM1OTU5WjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEzARBgNVBAoTCkdvb2dsZSBJbmMxFzAVBgNVBAsTDkNsb3VkIFBsYXRmb3JtMRowGAYDVQQDExFtZXRhZGF0YS5nb29nbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0tgGMffCABs61YFQVPOzE9cQ7K7ONbJV98z9FwX6RYSO2dqMeKYVSxCEsrFJNnUrGPxQNtB4qqauBvHOlEkQWkPRbB4OOYTb2qxMHYLOAlAGnkMR5kMDHaynlBMHWr5aMAHpFAMDp4PBqaFBPYCAoHoJkFBPHEMIRqH44yUhXy7CAjqgWDt5Kj1WPx6VDiKuGJjZX6U8YX5qQMjBxNnK9ZN+RBkk8r4g5x3KJHcnkrfbPJeZqGkl7FzJyVSNBnhIMBDkQrMSBWMFYYAqLnYQQPBwIDAQABo4IBCDCCAQQwHQYDVR0OBBYEFMKHaID9V+3rDIwVq0IpaqplsVAsMB8GA1UdIwQYMBaAFMKHaID9V+3rDIwVq0IpaqplsVAsMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBEGCWCGSAGG+EIBAQQEAwIFoDA8BgNVHR8ENTAzMDGgL6AthitodHRwOi8vY3JsLnBraS5nb29nL0dJQUcyL0dJQUcyLmNybDA9BggrBgEFBQcBAQQxMC8wLQYIKwYBBQUHMAGGIWh0dHA6Ly9vY3NwLnBraS5nb29nL0dJQUcyL0dJQUcyMA0GCSqGSIb3DQEBBAUAA4IBAQCOUe1CzSH4dRwGZ2IfkMPc5yLqHDFVzxEpXAI4xt7GLPIB2B1qJ7BFLJsjhFGwA6JPQV+L8PIkWGEbHqOJWHf1i8FRBVhzPSI7QiAdTPpKDk3KKiGdB5H0FT7M8Z/BFZW/aMLFiVtFO0qXfqT1xE2gAB4r8H2RR4z3BxV5bMEilEAnpKnm0E8cWJkrjXGdvD8mfm2qPB8f0mD0QnRQXQ + + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJAWTPZWhppJ3sBV2+LL2gDxLBFjzFqkKJIq3+6Ba83BqMoiMuKMt3lDiajy0iG3/G7G7Ua6YZIXCJ2lPzM3rnNS+GjLBEUCj1iPrI7HYpOrGqPnKpAHFpxfJMqBsYmBzDtSAGXqGfBXFhPwMdHpAFPBSqiAd0TKaBkVY18Nn6Q0yNkFzuqxVCt0R4qUkHv1XPPGBGfpqP3GBBPQIDAQABMA0GCSqGSIb3DQEBBAUAA4IBAQCUKuSKmT6YIE6oi1i9JYIbnVD3EBVh5bBefVqJEBMvN91dFR9eUALovJQlMkGnXZBQMllCLNaIcAqSxJR0pUi16SIi3cJvKbdDSbWsblRB6k+j5G7wOnJ3zIQbriMEA3yxqrRqVdnw+ygOHM/V8U6ykBt9QNIB+WJoYyW4YXZY+EFJsSSVt3o3qfGtblRXB4nOlhLKWj/d0Y9S2lqS2dFVbcxVnl0R0 + + + diff --git a/core/designsystem/src/test/java/com/example/catalogapp/core/designsystem/ExampleUnitTest.kt b/core/designsystem/src/test/java/com/example/catalogapp/core/designsystem/ExampleUnitTest.kt new file mode 100644 index 0000000..27138cc --- /dev/null +++ b/core/designsystem/src/test/java/com/example/catalogapp/core/designsystem/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.catalogapp.core.designsystem + +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/core/network/build.gradle.kts b/core/network/build.gradle.kts index f080c35..9faecb4 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.detekt) } android { @@ -22,6 +23,12 @@ android { } } +detekt { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/java") +} + dependencies { implementation(libs.retrofit.core) implementation(libs.retrofit.converter.gson) diff --git a/core/network/src/main/java/com/example/catalogapp/core/network/ConnectivityChecker.kt b/core/network/src/main/java/com/example/catalogapp/core/network/ConnectivityChecker.kt index 0669a47..1faf275 100644 --- a/core/network/src/main/java/com/example/catalogapp/core/network/ConnectivityChecker.kt +++ b/core/network/src/main/java/com/example/catalogapp/core/network/ConnectivityChecker.kt @@ -3,10 +3,11 @@ package com.example.catalogapp.core.network import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class ConnectivityChecker @Inject constructor( - private val context: Context + @param:ApplicationContext private val context: Context ) { fun isConnected(): Boolean { val connectivityManager = @@ -14,7 +15,6 @@ class ConnectivityChecker @Inject constructor( val network = connectivityManager.activeNetwork ?: return false val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } } diff --git a/core/network/src/main/java/com/example/catalogapp/core/network/NetworkModule.kt b/core/network/src/main/java/com/example/catalogapp/core/network/NetworkModule.kt index d67c960..fc30d02 100644 --- a/core/network/src/main/java/com/example/catalogapp/core/network/NetworkModule.kt +++ b/core/network/src/main/java/com/example/catalogapp/core/network/NetworkModule.kt @@ -24,7 +24,7 @@ object NetworkModule { fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) { - HttpLoggingInterceptor.Level.BODY + HttpLoggingInterceptor.Level.BASIC } else { HttpLoggingInterceptor.Level.NONE } diff --git a/data/product/.gitignore b/data/product/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/data/product/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/product/build.gradle.kts b/data/product/build.gradle.kts new file mode 100644 index 0000000..a40fbde --- /dev/null +++ b/data/product/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) + alias(libs.plugins.detekt) + jacoco +} + +android { + namespace = "com.example.catalogapp.data.product" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +// Tell Room where to export schema files +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +detekt { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/java") +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + // Domain contract this module implements + implementation(project(":domain:product")) + + // Network layer — fetches DTOs + implementation(project(":core:network")) + + // Common types — Result, UiState + implementation(project(":core:common")) + + // Room — local cache + implementation(libs.room.runtime) + implementation(libs.room.ktx) + implementation(libs.core.ktx) + ksp(libs.room.compiler) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.retrofit.core) + implementation(libs.gson) + + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.hilt.work) + ksp(libs.hilt.work.compiler) + + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.retrofit.core) + testImplementation(libs.okhttp.core) + testImplementation(libs.androidx.work.testing) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/data/product/consumer-rules.pro b/data/product/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/data/product/proguard-rules.pro b/data/product/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/data/product/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/data/product/schemas/com.example.catalogapp.data.product.local.CatalogDatabase/1.json b/data/product/schemas/com.example.catalogapp.data.product.local.CatalogDatabase/1.json new file mode 100644 index 0000000..400c233 --- /dev/null +++ b/data/product/schemas/com.example.catalogapp.data.product.local.CatalogDatabase/1.json @@ -0,0 +1,79 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "8430b94435aa3a15b79618d55c189157", + "entities": [ + { + "tableName": "products", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `price` REAL NOT NULL, `description` TEXT NOT NULL, `category` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `rating` REAL NOT NULL, `ratingCount` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "ratingCount", + "columnName": "ratingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8430b94435aa3a15b79618d55c189157')" + ] + } +} \ No newline at end of file diff --git a/data/product/src/androidTest/java/com/example/catalogapp/data/product/ExampleInstrumentedTest.kt b/data/product/src/androidTest/java/com/example/catalogapp/data/product/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f24a5b7 --- /dev/null +++ b/data/product/src/androidTest/java/com/example/catalogapp/data/product/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.catalogapp.data.product + +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.data.product.test", appContext.packageName) + } +} diff --git a/data/product/src/main/AndroidManifest.xml b/data/product/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8bdb7e1 --- /dev/null +++ b/data/product/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/di/DatabaseModule.kt b/data/product/src/main/java/com/example/catalogapp/data/product/di/DatabaseModule.kt new file mode 100644 index 0000000..cacb435 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/di/DatabaseModule.kt @@ -0,0 +1,34 @@ +package com.example.catalogapp.data.product.di + +import android.content.Context +import androidx.room.Room +import com.example.catalogapp.data.product.local.CatalogDatabase +import com.example.catalogapp.data.product.local.dao.ProductDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideCatalogDatabase( + @ApplicationContext context: Context + ): CatalogDatabase = Room.databaseBuilder( + context, + CatalogDatabase::class.java, + "catalog_database" + ) + .fallbackToDestructiveMigration(dropAllTables = true) + .build() + + @Provides + @Singleton + fun provideProductDao(database: CatalogDatabase): ProductDao = + database.productDao() +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/di/RepositoryModule.kt b/data/product/src/main/java/com/example/catalogapp/data/product/di/RepositoryModule.kt new file mode 100644 index 0000000..b752a75 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/di/RepositoryModule.kt @@ -0,0 +1,20 @@ +package com.example.catalogapp.data.product.di + +import com.example.catalogapp.data.product.repository.ProductRepositoryImpl +import com.example.catalogapp.domain.product.ProductRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindProductRepository( + impl: ProductRepositoryImpl + ): ProductRepository +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/di/WorkManagerModule.kt b/data/product/src/main/java/com/example/catalogapp/data/product/di/WorkManagerModule.kt new file mode 100644 index 0000000..3b58e65 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/di/WorkManagerModule.kt @@ -0,0 +1,19 @@ +package com.example.catalogapp.data.product.di + +import android.content.Context +import androidx.work.WorkManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object WorkManagerModule { + @Provides + @Singleton + fun provideWorkManager(@ApplicationContext context: Context): WorkManager = + WorkManager.getInstance(context) +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/local/CatalogDatabase.kt b/data/product/src/main/java/com/example/catalogapp/data/product/local/CatalogDatabase.kt new file mode 100644 index 0000000..ac96f65 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/local/CatalogDatabase.kt @@ -0,0 +1,15 @@ +package com.example.catalogapp.data.product.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.example.catalogapp.data.product.local.dao.ProductDao +import com.example.catalogapp.data.product.local.entity.ProductEntity + +@Database( + entities = [ProductEntity::class], + version = 1, + exportSchema = true +) +abstract class CatalogDatabase : RoomDatabase() { + abstract fun productDao(): ProductDao +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/local/dao/ProductDao.kt b/data/product/src/main/java/com/example/catalogapp/data/product/local/dao/ProductDao.kt new file mode 100644 index 0000000..99362c9 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/local/dao/ProductDao.kt @@ -0,0 +1,26 @@ +package com.example.catalogapp.data.product.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.catalogapp.data.product.local.entity.ProductEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ProductDao { + @Query("SELECT * FROM products ORDER BY title ASC") + fun getAllProducts(): Flow> + + @Query("SELECT * FROM products WHERE id = :id") + suspend fun getProductById(id: Int): ProductEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertProducts(products: List) + + @Query("DELETE FROM products") + suspend fun clearAll() + + @Query("SELECT cachedAt FROM products ORDER BY cachedAt ASC LIMIT 1") + suspend fun getOldestCacheTimestamp(): Long? +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/local/entity/ProductEntity.kt b/data/product/src/main/java/com/example/catalogapp/data/product/local/entity/ProductEntity.kt new file mode 100644 index 0000000..4a0ec43 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/local/entity/ProductEntity.kt @@ -0,0 +1,18 @@ +package com.example.catalogapp.data.product.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "products") +data class ProductEntity( + @PrimaryKey + val id: Int, + val title: String, + val price: Double, + val description: String, + val category: String, + val imageUrl: String, + val rating: Float, + val ratingCount: Int, + val cachedAt: Long = System.currentTimeMillis() +) diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/mapper/ProductMapper.kt b/data/product/src/main/java/com/example/catalogapp/data/product/mapper/ProductMapper.kt new file mode 100644 index 0000000..ceae092 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/mapper/ProductMapper.kt @@ -0,0 +1,43 @@ +package com.example.catalogapp.data.product.mapper + +import com.example.catalogapp.core.network.dto.ProductDto +import com.example.catalogapp.data.product.local.entity.ProductEntity +import com.example.catalogapp.domain.product.Product + +// ─── Network DTO → Domain Model ─────────────────────────────────────────────── +fun ProductDto.toDomain(): Product = Product( + id = id, + title = title, + price = price, + description = description, + category = category, + imageUrl = imageUrl, + rating = rating?.rate ?: 0f +) + +// ─── Network DTO → Room Entity ──────────────────────────────────────────────── +fun ProductDto.toEntity(): ProductEntity = ProductEntity( + id = id, + title = title, + price = price, + description = description, + category = category, + imageUrl = imageUrl, + rating = rating?.rate ?: 0f, + ratingCount = rating?.count ?: 0 +) + +// ─── Room Entity → Domain Model ─────────────────────────────────────────────── +fun ProductEntity.toDomain(): Product = Product( + id = id, + title = title, + price = price, + description = description, + category = category, + imageUrl = imageUrl, + rating = rating +) + +// ─── List extensions ────────────────────────────────────────────────────────── +fun List.toEntityList(): List = map { it.toEntity() } +fun List.toDomainList(): List = map { it.toDomain() } diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/repository/ProductRepositoryImpl.kt b/data/product/src/main/java/com/example/catalogapp/data/product/repository/ProductRepositoryImpl.kt new file mode 100644 index 0000000..15afc25 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/repository/ProductRepositoryImpl.kt @@ -0,0 +1,105 @@ +package com.example.catalogapp.data.product.repository + +import com.example.catalogapp.core.network.ConnectivityChecker +import com.example.catalogapp.core.network.NetworkError +import com.example.catalogapp.core.network.api.ProductApiService +import com.example.catalogapp.data.product.local.dao.ProductDao +import com.example.catalogapp.data.product.mapper.toDomain +import com.example.catalogapp.data.product.mapper.toDomainList +import com.example.catalogapp.data.product.mapper.toEntityList +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.domain.product.ProductRepository +import com.example.catalogapp.domain.product.SyncError +import com.example.catalogapp.domain.product.SyncResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import java.io.IOException +import java.net.SocketTimeoutException +import javax.inject.Inject +import retrofit2.HttpException + +class ProductRepositoryImpl @Inject constructor( + private val apiService: ProductApiService, + private val productDao: ProductDao, + private val connectivityChecker: ConnectivityChecker +) : ProductRepository { + + companion object { + // Cache is considered stale after 30 minutes + private const val CACHE_THRESHOLD_MS = 30 * 60 * 1000L + } + + override fun getProducts(): Flow> { + return productDao.getAllProducts() + .map { entities -> entities.toDomainList() } + .onStart { + // On first collection, decide whether to refresh from network + if (shouldRefreshCache()) { + refreshProducts() + } + } + .catch { _ -> + // Room Flow errors are rare but handle defensively + emit(emptyList()) + } + } + + override suspend fun getProductById(id: Int): Product? { + return productDao.getProductById(id)?.toDomain() + ?: fetchProductByIdFromNetwork(id) + } + + override suspend fun syncProducts(): SyncResult = refreshProducts() + // ─── Private helpers ────────────────────────────────────────────────────── + + private suspend fun shouldRefreshCache(): Boolean { + val oldestTimestamp = productDao.getOldestCacheTimestamp() ?: return true + return System.currentTimeMillis() - oldestTimestamp > CACHE_THRESHOLD_MS + } + + // refreshProducts() becomes the single source of truth for both callers + private suspend fun refreshProducts(): SyncResult { + if (!connectivityChecker.isConnected()) { + return SyncResult.Error(SyncError.NoInternet) + } + return try { + val products = apiService.getProducts() + productDao.clearAll() + productDao.insertProducts(products.toEntityList()) + SyncResult.Success + } catch (e: SocketTimeoutException) { + logError(NetworkError.Timeout, e) + SyncResult.Error(SyncError.Timeout) + } catch (e: IOException) { + logError(NetworkError.NoInternet, e) + SyncResult.Error(SyncError.NoInternet) + } catch (e: HttpException) { + logError(NetworkError.ServerError(e.code(), e.message()), e) + SyncResult.Error(SyncError.ServerError(e.code(), e.message())) + } + } + + private suspend fun fetchProductByIdFromNetwork(id: Int): Product? { + if (!connectivityChecker.isConnected()) return null + return try { + apiService.getProductById(id).toDomain() + } catch (e: SocketTimeoutException) { + logError(NetworkError.Timeout, e) + null + } catch (e: IOException) { + logError(NetworkError.NoInternet, e) + null + } catch (e: HttpException) { + logError(NetworkError.ServerError(e.code(), e.message()), e) + null + } + } + + private fun logError(networkError: NetworkError, cause: Throwable) { + // In a real production app this would go to Firebase Crashlytics or Timber + // For now: structured logging so the exception is not silently swallowed + println("NetworkError: ${networkError::class.simpleName} caused by: ${cause.message}") + } +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/sync/SyncScheduler.kt b/data/product/src/main/java/com/example/catalogapp/data/product/sync/SyncScheduler.kt new file mode 100644 index 0000000..e388186 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/sync/SyncScheduler.kt @@ -0,0 +1,46 @@ +package com.example.catalogapp.data.product.sync + +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.example.catalogapp.data.product.worker.ProductSyncWorker +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class SyncScheduler @Inject constructor( + private val workManager: WorkManager +) { + fun schedulePeriodicSync() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() + + val syncRequest = PeriodicWorkRequestBuilder( + repeatInterval = SYNC_INTERVAL_HOURS, + repeatIntervalTimeUnit = TimeUnit.HOURS + ) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_DELAY_MINUTES, TimeUnit.MINUTES) + .addTag(ProductSyncWorker.WORK_NAME) + .build() + + workManager.enqueueUniquePeriodicWork( + ProductSyncWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + syncRequest + ) + } + + fun cancelSync() { + workManager.cancelUniqueWork(ProductSyncWorker.WORK_NAME) + } + + companion object { + private const val SYNC_INTERVAL_HOURS = 6L + private const val BACKOFF_DELAY_MINUTES = 15L + } +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/worker/ProductSyncWorker.kt b/data/product/src/main/java/com/example/catalogapp/data/product/worker/ProductSyncWorker.kt new file mode 100644 index 0000000..898b820 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/worker/ProductSyncWorker.kt @@ -0,0 +1,27 @@ +package com.example.catalogapp.data.product.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.example.catalogapp.domain.product.ProductRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class ProductSyncWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val productRepository: ProductRepository +) : CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result = + productRepository.syncProducts().toWorkerResult(runAttemptCount) + + companion object { + const val WORK_NAME = "ProductSyncWorker" + } +} + + + diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/worker/SyncResultMapper.kt b/data/product/src/main/java/com/example/catalogapp/data/product/worker/SyncResultMapper.kt new file mode 100644 index 0000000..30f19d5 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/worker/SyncResultMapper.kt @@ -0,0 +1,28 @@ +package com.example.catalogapp.data.product.worker + +import androidx.work.ListenableWorker.Result +import com.example.catalogapp.domain.product.SyncError +import com.example.catalogapp.domain.product.SyncResult + +/** + * Pure mapping from domain SyncResult to WorkManager's Result. + * No coroutines, no Android framework, no WorkManager instantiation — + * testable with plain JUnit in milliseconds. + */ +internal fun SyncResult.toWorkerResult(runAttemptCount: Int): Result = when (this) { + is SyncResult.Success -> Result.success() + is SyncResult.Error -> when (val error = reason) { + is SyncError.Timeout, + is SyncError.NoInternet -> retryOrFail(runAttemptCount) + + is SyncError.ServerError -> + if (error.code in RETRYABLE_HTTP_CODES) retryOrFail(runAttemptCount) else Result.failure() + } +} + +private fun retryOrFail(runAttemptCount: Int): Result = + if (runAttemptCount < MAX_RETRY_ATTEMPTS) Result.retry() else Result.failure() + +private const val MAX_RETRY_ATTEMPTS = 3 +private val RETRYABLE_HTTP_CODES = 500..599 + diff --git a/data/product/src/test/java/com/example/catalogapp/data/product/ExampleUnitTest.kt b/data/product/src/test/java/com/example/catalogapp/data/product/ExampleUnitTest.kt new file mode 100644 index 0000000..4776462 --- /dev/null +++ b/data/product/src/test/java/com/example/catalogapp/data/product/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.catalogapp.data.product + +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/data/product/src/test/java/com/example/catalogapp/data/product/repository/ProductRepositoryImplTest.kt b/data/product/src/test/java/com/example/catalogapp/data/product/repository/ProductRepositoryImplTest.kt new file mode 100644 index 0000000..fa27f22 --- /dev/null +++ b/data/product/src/test/java/com/example/catalogapp/data/product/repository/ProductRepositoryImplTest.kt @@ -0,0 +1,246 @@ +package com.example.catalogapp.data.product.repository + +import com.example.catalogapp.core.network.ConnectivityChecker +import com.example.catalogapp.core.network.api.ProductApiService +import com.example.catalogapp.core.network.dto.ProductDto +import com.example.catalogapp.core.network.dto.RatingDto +import com.example.catalogapp.data.product.local.dao.ProductDao +import com.example.catalogapp.data.product.local.entity.ProductEntity +import com.example.catalogapp.domain.product.SyncError +import com.example.catalogapp.domain.product.SyncResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Before +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.net.SocketTimeoutException + +class ProductRepositoryImplTest { + private val apiService: ProductApiService = mockk() + private val productDao: ProductDao = mockk() + private val connectivityChecker: ConnectivityChecker = mockk() + + private lateinit var repository: ProductRepositoryImpl + + private val fakeEntity = ProductEntity( + id = 1, + title = "Gold Ring", + price = 4500.0, + description = "22K gold ring", + category = "rings", + imageUrl = "https://example.com/ring.png", + rating = 4.5f, + ratingCount = 100, + cachedAt = System.currentTimeMillis() + ) + + private val fakeDto = ProductDto( + id = 2, + title = "Silver Necklace", + price = 2500.0, + description = "Pure silver necklace", + category = "necklaces", + imageUrl = "https://example.com/necklace.png", + rating = RatingDto(rate = 4.2f, count = 50) + ) + + @Before + fun setUp() { + repository = ProductRepositoryImpl(apiService, productDao, connectivityChecker) + } + + @Test + fun `getProducts returns mapped domain list from Room when cache is fresh`() = runTest { + // Arrange — cache is fresh (cachedAt is now) + val freshTimestamp = System.currentTimeMillis() + every { productDao.getAllProducts() } returns flowOf(listOf(fakeEntity)) + coEvery { productDao.getOldestCacheTimestamp() } returns freshTimestamp + + // Act + val result = repository.getProducts().first() + + // Assert — entity mapped to domain correctly + assertEquals(1, result.size) + assertEquals("Gold Ring", result[0].title) + assertEquals(4500.0, result[0].price, 0.0) + } + + @Test + fun `getProducts triggers network refresh when cache is stale`() = runTest { + // Arrange — cache is stale (older than 30 minutes) + val staleTimestamp = System.currentTimeMillis() - (31 * 60 * 1000L) + every { productDao.getAllProducts() } returns flowOf(listOf(fakeEntity)) + coEvery { productDao.getOldestCacheTimestamp() } returns staleTimestamp + every { connectivityChecker.isConnected() } returns true + coEvery { apiService.getProducts() } returns listOf(fakeDto) + coEvery { productDao.clearAll() } returns Unit + coEvery { productDao.insertProducts(any()) } returns Unit + + // Act + repository.getProducts().first() + + // Assert — network was called and DAO was updated + coVerify { apiService.getProducts() } + coVerify { productDao.clearAll() } + coVerify { productDao.insertProducts(any()) } + } + + @Test + fun `getProducts does not call network when offline even if cache is stale`() = runTest { + // Arrange — stale cache + no internet + val staleTimestamp = System.currentTimeMillis() - (31 * 60 * 1000L) + every { productDao.getAllProducts() } returns flowOf(listOf(fakeEntity)) + coEvery { productDao.getOldestCacheTimestamp() } returns staleTimestamp + every { connectivityChecker.isConnected() } returns false + + // Act + repository.getProducts().first() + + // Assert — network was never called despite stale cache + coVerify(exactly = 0) { apiService.getProducts() } + } + + @Test + fun `getProductById returns cached entity when available`() = runTest { + // Arrange + coEvery { productDao.getProductById(1) } returns fakeEntity + + // Act + val result = repository.getProductById(1) + + // Assert + assertEquals("Gold Ring", result?.title) + // Network should never have been called + coVerify(exactly = 0) { apiService.getProductById(any()) } + } + + @Test + fun `getProductById fetches from network when not in cache and connected`() = runTest { + // Arrange — not in cache, connected + coEvery { productDao.getProductById(2) } returns null + every { connectivityChecker.isConnected() } returns true + coEvery { apiService.getProductById(2) } returns fakeDto + + // Act + val result = repository.getProductById(2) + + // Assert + assertEquals("Silver Necklace", result?.title) + coVerify { apiService.getProductById(2) } + } + + @Test + fun `getProductById returns null when not in cache and offline`() = runTest { + // Arrange — not in cache, offline + coEvery { productDao.getProductById(99) } returns null + every { connectivityChecker.isConnected() } returns false + + // Act + val result = repository.getProductById(99) + + // Assert + assertNull(result) + coVerify(exactly = 0) { apiService.getProductById(any()) } + } + + @Test + fun `refreshProducts handles SocketTimeoutException silently and serves stale cache`() = + runTest { + // Arrange — stale cache, connected, but network times out + val staleTimestamp = System.currentTimeMillis() - (31 * 60 * 1000L) + every { productDao.getAllProducts() } returns flowOf(listOf(fakeEntity)) + coEvery { productDao.getOldestCacheTimestamp() } returns staleTimestamp + every { connectivityChecker.isConnected() } returns true + coEvery { apiService.getProducts() } throws SocketTimeoutException() + coEvery { productDao.clearAll() } returns Unit + + // Act — should not throw, stale cache served silently + val result = repository.getProducts().first() + + // Assert — user still sees cached data, no crash + assertEquals(1, result.size) + assertEquals("Gold Ring", result[0].title) + } + + @Test + fun `refreshProducts handles HttpException silently and serves stale cache`() = runTest { + // Arrange — stale cache, connected, but server returns 500 + val staleTimestamp = System.currentTimeMillis() - (31 * 60 * 1000L) + every { productDao.getAllProducts() } returns flowOf(listOf(fakeEntity)) + coEvery { productDao.getOldestCacheTimestamp() } returns staleTimestamp + every { connectivityChecker.isConnected() } returns true + coEvery { apiService.getProducts() } throws HttpException( + Response.error( + 500, + "Internal Server Error".toResponseBody(null) + ) + ) + coEvery { productDao.clearAll() } returns Unit + + // Act — should not throw, stale cache served silently + val result = repository.getProducts().first() + + // Assert — user still sees cached data, no crash + assertEquals(1, result.size) + } + + @Test + fun `mapper handles null rating defensively and defaults to zero`() = runTest { + // Arrange — DTO with null rating (real FakeStore API edge case) + val dtoWithNullRating = ProductDto( + id = 3, + title = "Test Product", + price = 100.0, + description = "Test description", + category = "test", + imageUrl = "https://example.com/test.png", + rating = null + ) + coEvery { productDao.getProductById(3) } returns null + every { connectivityChecker.isConnected() } returns true + coEvery { apiService.getProductById(3) } returns dtoWithNullRating + + // Act + val result = repository.getProductById(3) + + // Assert — rating defaults to 0f, no NullPointerException + assertEquals(0f, result?.rating) + assertEquals("Test Product", result?.title) + } + + @Test + fun `syncProducts returns Success when sync completes`() = runTest { + every { connectivityChecker.isConnected() } returns true + coEvery { apiService.getProducts() } returns listOf(fakeDto) + coEvery { productDao.clearAll() } returns Unit + coEvery { productDao.insertProducts(any()) } returns Unit + + val result = repository.syncProducts() + + assertTrue(result is SyncResult.Success) + coVerify { apiService.getProducts() } + coVerify { productDao.clearAll() } + coVerify { productDao.insertProducts(any()) } + } + + @Test + fun `syncProducts returns Error NoInternet when disconnected`() = runTest { + every { connectivityChecker.isConnected() } returns false + + val result = repository.syncProducts() + + assertTrue(result is SyncResult.Error) + assertTrue((result as SyncResult.Error).reason is SyncError.NoInternet) + coVerify(exactly = 0) { apiService.getProducts() } + } +} diff --git a/data/product/src/test/java/com/example/catalogapp/data/product/worker/SyncResultMapperTest.kt b/data/product/src/test/java/com/example/catalogapp/data/product/worker/SyncResultMapperTest.kt new file mode 100644 index 0000000..c3d6795 --- /dev/null +++ b/data/product/src/test/java/com/example/catalogapp/data/product/worker/SyncResultMapperTest.kt @@ -0,0 +1,48 @@ +package com.example.catalogapp.data.product.worker + +import androidx.work.ListenableWorker.Result +import com.example.catalogapp.domain.product.SyncError +import com.example.catalogapp.domain.product.SyncResult +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncResultMapperTest { + + @Test + fun `Success maps to Result Success`() { + val result = SyncResult.Success.toWorkerResult(runAttemptCount = 0) + assertTrue(result is Result.Success) + } + + @Test + fun `Timeout retries under max attempts`() { + val result = SyncResult.Error(SyncError.Timeout).toWorkerResult(runAttemptCount = 1) + assertTrue(result is Result.Retry) + } + + @Test + fun `Timeout fails after max attempts exhausted`() { + val result = SyncResult.Error(SyncError.Timeout).toWorkerResult(runAttemptCount = 3) + assertTrue(result is Result.Failure) + } + + @Test + fun `NoInternet retries under max attempts`() { + val result = SyncResult.Error(SyncError.NoInternet).toWorkerResult(runAttemptCount = 0) + assertTrue(result is Result.Retry) + } + + @Test + fun `5xx server error retries`() { + val result = SyncResult.Error(SyncError.ServerError(503, "Unavailable")) + .toWorkerResult(runAttemptCount = 0) + assertTrue(result is Result.Retry) + } + + @Test + fun `4xx server error fails fast without retry`() { + val result = SyncResult.Error(SyncError.ServerError(400, "Bad Request")) + .toWorkerResult(runAttemptCount = 0) + assertTrue(result is Result.Failure) + } +} \ No newline at end of file diff --git a/domain/product/build.gradle.kts b/domain/product/build.gradle.kts index cac33df..151371f 100644 --- a/domain/product/build.gradle.kts +++ b/domain/product/build.gradle.kts @@ -1,5 +1,17 @@ plugins { alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.detekt) +} + +jacoco { + toolVersion = "0.8.12" +} + +detekt { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/java") } dependencies { diff --git a/domain/product/src/main/java/com/example/catalogapp/domain/product/ProductRepository.kt b/domain/product/src/main/java/com/example/catalogapp/domain/product/ProductRepository.kt index 01c7f1b..df5147f 100644 --- a/domain/product/src/main/java/com/example/catalogapp/domain/product/ProductRepository.kt +++ b/domain/product/src/main/java/com/example/catalogapp/domain/product/ProductRepository.kt @@ -5,4 +5,6 @@ import kotlinx.coroutines.flow.Flow interface ProductRepository { fun getProducts(): Flow> suspend fun getProductById(id: Int): Product? + suspend fun syncProducts(): SyncResult } + diff --git a/domain/product/src/main/java/com/example/catalogapp/domain/product/SearchProductsUseCase.kt b/domain/product/src/main/java/com/example/catalogapp/domain/product/SearchProductsUseCase.kt new file mode 100644 index 0000000..97dae6d --- /dev/null +++ b/domain/product/src/main/java/com/example/catalogapp/domain/product/SearchProductsUseCase.kt @@ -0,0 +1,14 @@ +package com.example.catalogapp.domain.product + +import javax.inject.Inject + +class SearchProductsUseCase @Inject constructor() { + operator fun invoke(products: List, query: String): List { + if (query.isBlank()) return emptyList() + return products.filter { product -> + product.title.contains(query, ignoreCase = true) || + product.category.contains(query, ignoreCase = true) + } + } +} + diff --git a/domain/product/src/main/java/com/example/catalogapp/domain/product/SyncResult.kt b/domain/product/src/main/java/com/example/catalogapp/domain/product/SyncResult.kt new file mode 100644 index 0000000..117e139 --- /dev/null +++ b/domain/product/src/main/java/com/example/catalogapp/domain/product/SyncResult.kt @@ -0,0 +1,12 @@ +package com.example.catalogapp.domain.product + +sealed class SyncResult { + object Success : SyncResult() + data class Error(val reason: SyncError) : SyncResult() +} + +sealed class SyncError { + object Timeout : SyncError() + object NoInternet : SyncError() + data class ServerError(val code: Int, val message: String?) : SyncError() +} diff --git a/domain/product/src/test/java/com/example/catalogapp/domain/product/SearchProductsUseCaseTest.kt b/domain/product/src/test/java/com/example/catalogapp/domain/product/SearchProductsUseCaseTest.kt new file mode 100644 index 0000000..1b01bc3 --- /dev/null +++ b/domain/product/src/test/java/com/example/catalogapp/domain/product/SearchProductsUseCaseTest.kt @@ -0,0 +1,63 @@ +package com.example.catalogapp.domain.product + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class SearchProductsUseCaseTest { + + private val useCase = SearchProductsUseCase() + + private val products = listOf( + Product( + id = 1, title = "Silver Earrings", price = 120.0, + description = "d", category = "Jewelry", imageUrl = "", rating = 4.8f + ), + Product( + id = 2, title = "Running Shoes", price = 185.0, + description = "d", category = "Footwear", imageUrl = "", rating = 4.9f + ), + Product( + id = 3, title = "Gold Necklace", price = 300.0, + description = "d", category = "Jewelry", imageUrl = "", rating = 4.7f + ) + ) + + @Test + fun `blank query returns empty list`() { + val result = useCase(products, "") + assertTrue(result.isEmpty()) + } + + @Test + fun `matches by title substring`() { + val result = useCase(products, "Earrings") + assertEquals(1, result.size) + assertEquals("Silver Earrings", result.first().title) + } + + @Test + fun `matches by category substring`() { + val result = useCase(products, "Jewelry") + assertEquals(2, result.size) + assertTrue(result.all { it.category == "Jewelry" }) + } + + @Test + fun `match is case-insensitive`() { + val result = useCase(products, "SILVER") + assertEquals(1, result.size) + } + + @Test + fun `no matches returns empty list`() { + val result = useCase(products, "nonexistent") + assertTrue(result.isEmpty()) + } + + @Test + fun `empty product list returns empty list regardless of query`() { + val result = useCase(emptyList(), "silver") + assertTrue(result.isEmpty()) + } +} \ No newline at end of file diff --git a/domain/product/src/test/java/com/example/catalogapp/domain/product/SyncResultTest.kt b/domain/product/src/test/java/com/example/catalogapp/domain/product/SyncResultTest.kt new file mode 100644 index 0000000..25fe0a5 --- /dev/null +++ b/domain/product/src/test/java/com/example/catalogapp/domain/product/SyncResultTest.kt @@ -0,0 +1,18 @@ +package com.example.catalogapp.domain.product + +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncResultTest { + @Test + fun `Success is a SyncResult`() { + assertTrue(true) + } + + @Test + fun `Error wraps SyncError correctly`() { + val result = SyncResult.Error(SyncError.ServerError(500, "Internal error")) + assertTrue(true) + assertTrue(result.reason is SyncError.ServerError) + } +} \ No newline at end of file diff --git a/feature/catalog/.gitignore b/feature/catalog/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/catalog/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/catalog/build.gradle.kts b/feature/catalog/build.gradle.kts new file mode 100644 index 0000000..7ce0e1c --- /dev/null +++ b/feature/catalog/build.gradle.kts @@ -0,0 +1,77 @@ +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.catalog" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +detekt { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/java") +} + +dependencies { + // Domain — use cases and models + implementation(project(":domain:product")) + + // Common — UiState, Result + implementation(project(":core:common")) + + // Design system — theme, components + implementation(project(":core:designsystem")) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + // Lifecycle + implementation(libs.androidx.lifecycle.runtime.ktx) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Hilt Navigation Compose + implementation(libs.hilt.navigation.compose) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Coil + implementation(libs.coil.compose) + + implementation(libs.androidx.compose.material.icons.extended) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) +} + +jacoco { + toolVersion = "0.8.12" +} diff --git a/feature/catalog/consumer-rules.pro b/feature/catalog/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/catalog/proguard-rules.pro b/feature/catalog/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature/catalog/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/catalog/src/androidTest/java/com/example/catalogapp/feature/catalog/ExampleInstrumentedTest.kt b/feature/catalog/src/androidTest/java/com/example/catalogapp/feature/catalog/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..a18dc28 --- /dev/null +++ b/feature/catalog/src/androidTest/java/com/example/catalogapp/feature/catalog/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.catalogapp.feature.catalog + +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.catalog.test", appContext.packageName) + } +} diff --git a/feature/catalog/src/main/AndroidManifest.xml b/feature/catalog/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8bdb7e1 --- /dev/null +++ b/feature/catalog/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogContract.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogContract.kt new file mode 100644 index 0000000..15ff2a6 --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogContract.kt @@ -0,0 +1,51 @@ +package com.example.catalogapp.feature.catalog + +import com.example.catalogapp.domain.product.Product + +// ─── UI State — single source of truth for the catalog screen ───────────────── +// Data class (not sealed) because multiple states can be true simultaneously: +// isRefreshing=true while products are still visible during pull-to-refresh +data class CatalogUiState( + val products: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, + val selectedCategory: String = "All" +) { + // Derived state — computed from existing state, never stored separately + // This prevents impossible state combinations like isLoading=true + showProducts=true + val showLoading: Boolean get() = isLoading && products.isEmpty() + val showError: Boolean get() = error != null && !isLoading && products.isEmpty() + val showEmpty: Boolean get() = !isLoading && error == null && products.isEmpty() + val showProducts: Boolean get() = products.isNotEmpty() + + // Categories derived from product list — no separate state needed + val categories: List get() = listOf("All") + + products.map { it.category } + .distinct() + .sorted() + + // Filtered products based on selected category + val filteredProducts: List get() = if (selectedCategory == "All") { + products + } else { + products.filter { it.category == selectedCategory } + } +} + +// ─── Intent — every possible user action, explicitly named ──────────────────── +sealed class CatalogIntent { + object LoadProducts : CatalogIntent() + object RefreshProducts : CatalogIntent() + object RetryLoad : CatalogIntent() + object SearchClicked : CatalogIntent() + data class SelectCategory(val category: String) : CatalogIntent() + data class ProductClicked(val productId: Int) : CatalogIntent() +} + +// ─── Effect — one-time events, consumed once, never replayed ────────────────── +sealed class CatalogEffect { + data class NavigateToDetail(val productId: Int) : CatalogEffect() + object NavigateToSearch : CatalogEffect() + data class ShowSnackbar(val message: String) : CatalogEffect() +} diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogScreen.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogScreen.kt new file mode 100644 index 0000000..4fe682c --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogScreen.kt @@ -0,0 +1,159 @@ +package com.example.catalogapp.feature.catalog + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.feature.catalog.components.CatalogBody +import com.example.catalogapp.feature.catalog.components.CatalogBottomBar +import com.example.catalogapp.feature.catalog.components.CatalogFab +import com.example.catalogapp.feature.catalog.components.CatalogTopBar + +private const val SEARCH_TAB_INDEX = 1 + +// ─── Top-level screen — owns ViewModel, handles effects ─────────────────────── +@Composable +fun CatalogScreen( + onNavigateToDetail: (Int) -> Unit, + onNavigateToSearch: () -> Unit, + viewModel: CatalogViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is CatalogEffect.NavigateToDetail -> onNavigateToDetail(effect.productId) + is CatalogEffect.NavigateToSearch -> onNavigateToSearch() + is CatalogEffect.ShowSnackbar -> {} + } + } + } + CatalogContent( + uiState = uiState, + onIntent = viewModel::processIntent + ) +} + +// ─── Stateless content composable — thin composition root ──────────────────── +// Scaffold slots (top bar, bottom bar, fab, body) each live in their own file +// under components/, so this stays small and easy to scan (TooManyFunctions / +// LongMethod safe by construction rather than by suppression). +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CatalogContent( + uiState: CatalogUiState, + onIntent: (CatalogIntent) -> Unit +) { + var selectedNavItem by remember { mutableIntStateOf(0) } + + Scaffold( + topBar = { + CatalogTopBar(onSearchClick = { onIntent(CatalogIntent.SearchClicked) }) + }, + bottomBar = { + CatalogBottomBar( + selectedIndex = selectedNavItem, + onItemSelected = { index -> + when (index) { + SEARCH_TAB_INDEX -> onIntent(CatalogIntent.SearchClicked) + else -> selectedNavItem = + index // Home/Wishlist/Profile — visual only for now + } + } + ) + }, + floatingActionButton = { + CatalogFab(onClick = { }) + } + ) { paddingValues -> + CatalogBody( + uiState = uiState, + onIntent = onIntent, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) + } +} + +// ─── Previews ───────────────────────────────────────────────────────────────── +@Preview(showBackground = true, name = "Catalog - Products") +@Composable +internal fun CatalogContentPreview() { + CatalogTheme { + CatalogContent( + uiState = CatalogUiState( + products = previewProducts, + selectedCategory = "All" + ), + onIntent = {} + ) + } +} + +@Preview(showBackground = true, name = "Catalog - Loading") +@Composable +internal fun CatalogLoadingPreview() { + CatalogTheme { + CatalogContent( + uiState = CatalogUiState(isLoading = true), + onIntent = {} + ) + } +} + +@Preview(showBackground = true, name = "Catalog - Error") +@Composable +internal fun CatalogErrorPreview() { + CatalogTheme { + CatalogContent( + uiState = CatalogUiState( + error = "Unable to load products." + ), + onIntent = {} + ) + } +} + +private val previewProducts = listOf( + Product( + 1, + "Premium Stealth Titanium Smartwatch Series 9", + 24999.0, + "Great watch", + "electronics", + "", + 4.8f + ), + Product( + 2, + "Linen Heritage Slim-Fit Kurta - Emerald", + 3450.0, + "Great kurta", + "men's clothing", + "", + 4.6f + ), + Product( + 3, + "SonicFlow Noise-Cancelling Headphones", + 18200.0, + "Great headphones", + "electronics", + "", + 4.9f + ), + Product(4, "Geometric Prism Gold Earrings", 12499.0, "Great earrings", "jewelery", "", 4.5f) +) diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogViewModel.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogViewModel.kt new file mode 100644 index 0000000..9ed6935 --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/CatalogViewModel.kt @@ -0,0 +1,99 @@ +package com.example.catalogapp.feature.catalog + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.catalogapp.domain.product.GetProductsUseCase +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.catch +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CatalogViewModel @Inject constructor( + private val getProductsUseCase: GetProductsUseCase +) : ViewModel() { + + // ─── State — single StateFlow, single source of truth ───────────────────── + private val _uiState = MutableStateFlow(CatalogUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // ─── Effects — Channel for one-time events ───────────────────────────────── + // Channel.BUFFERED ensures effects aren't dropped if UI isn't collecting yet + private val _effect = Channel(Channel.BUFFERED) + val effect = _effect.receiveAsFlow() + + init { + processIntent(CatalogIntent.LoadProducts) + } + + // ─── Single entry point for ALL user actions ─────────────────────────────── + // This is the core MVI pattern — UI calls only this function + fun processIntent(intent: CatalogIntent) { + when (intent) { + is CatalogIntent.LoadProducts -> loadProducts() + is CatalogIntent.RefreshProducts -> refreshProducts() + is CatalogIntent.RetryLoad -> loadProducts() + is CatalogIntent.SearchClicked -> navigateToSearch() + is CatalogIntent.SelectCategory -> selectCategory(intent.category) + is CatalogIntent.ProductClicked -> navigateToDetail(intent.productId) + } + } + + // ─── Private handlers ────────────────────────────────────────────────────── + private fun loadProducts() = fetchProducts(showAsRefresh = false) + private fun refreshProducts() = fetchProducts(showAsRefresh = true) + + private fun fetchProducts(showAsRefresh: Boolean) { + viewModelScope.launch { + _uiState.update { + if (showAsRefresh) it.copy(isRefreshing = true) else it.copy( + isLoading = true, + error = null + ) + } + + getProductsUseCase() + .catch { exception -> + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + error = exception.message ?: "Failed to load products" + ) + } + } + .collect { products -> + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + products = products, + error = null + ) + } + } + } + } + + private fun selectCategory(category: String) { + _uiState.update { it.copy(selectedCategory = category) } + } + + private fun navigateToDetail(productId: Int) { + viewModelScope.launch { + _effect.send(CatalogEffect.NavigateToDetail(productId)) + } + } + + private fun navigateToSearch() { + viewModelScope.launch { + _effect.send(CatalogEffect.NavigateToSearch) + } + } +} diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogbody.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogbody.kt new file mode 100644 index 0000000..6122083 --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogbody.kt @@ -0,0 +1,104 @@ +package com.example.catalogapp.feature.catalog.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.catalogapp.core.designsystem.components.CatalogEmptyState +import com.example.catalogapp.core.designsystem.components.CatalogErrorState +import com.example.catalogapp.core.designsystem.components.CategoryChipRowSkeleton +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.feature.catalog.CatalogIntent +import com.example.catalogapp.feature.catalog.CatalogUiState + +private const val CROSSFADE_DURATION_MS = 400 + +// Represents WHICH content state we're in — used as the AnimatedContent key. +// Without this, AnimatedContent can't distinguish "loading -> success" from +// "loading -> error", since both are just showLoading going true -> false. +private enum class CatalogContentState { + Loading, Error, Empty, Products +} + +private fun CatalogUiState.contentState(): CatalogContentState = when { + showLoading -> CatalogContentState.Loading + showError -> CatalogContentState.Error + showEmpty -> CatalogContentState.Empty + else -> CatalogContentState.Products +} + +// ─── Body — pull-to-refresh + category chips + content states ──────────────── +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CatalogBody( + uiState: CatalogUiState, + onIntent: (CatalogIntent) -> Unit, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + val state = uiState.contentState() + + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = { + onIntent(CatalogIntent.RefreshProducts) + }, + modifier = modifier + ) { + Column(modifier = Modifier.fillMaxSize()) { + when (state) { + CatalogContentState.Loading -> CategoryChipRowSkeleton( + modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.md) + ) + + else -> if (uiState.categories.size > 1) { + CategoryChips( + categories = uiState.categories, + selectedCategory = uiState.selectedCategory, + onCategorySelected = { category -> + onIntent( + CatalogIntent.SelectCategory( + category + ) + ) + } + ) + } + } + + AnimatedContent( + targetState = uiState.contentState(), + transitionSpec = { + fadeIn(animationSpec = tween(CROSSFADE_DURATION_MS)) togetherWith + fadeOut(animationSpec = tween(CROSSFADE_DURATION_MS)) + }, + label = "catalog_content_crossfade" + ) { state -> + when (state) { + CatalogContentState.Loading -> ProductGridSkeleton() + CatalogContentState.Error -> CatalogErrorState( + message = uiState.error ?: "Unknown error", + onRetry = { onIntent(CatalogIntent.RetryLoad) } + ) + + CatalogContentState.Empty -> CatalogEmptyState( + message = "No products available right now." + ) + + CatalogContentState.Products -> ProductGrid( + products = uiState.filteredProducts, + onProductClick = { id -> onIntent(CatalogIntent.ProductClicked(id)) } + ) + } + } + } + } +} diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogbottombar.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogbottombar.kt new file mode 100644 index 0000000..8b53b3a --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogbottombar.kt @@ -0,0 +1,74 @@ +package com.example.catalogapp.feature.catalog.components + +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.Home +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick + +private data class BottomNavItem( + val label: String, + val icon: ImageVector, + val selectedIcon: ImageVector +) + +private val bottomNavItems = listOf( + BottomNavItem("Home", Icons.Default.Home, Icons.Default.Home), + BottomNavItem("Search", Icons.Default.Search, Icons.Default.Search), + BottomNavItem("Wishlist", Icons.Default.FavoriteBorder, Icons.Default.Favorite), + BottomNavItem("Profile", Icons.Default.Menu, Icons.Default.Menu) +) + +// ─── Bottom Navigation — matches Stitch: 4 tabs, active pill ────────────────── +@Composable +internal fun CatalogBottomBar( + selectedIndex: Int, + onItemSelected: (Int) -> Unit +) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 0.dp + ) { + bottomNavItems.forEachIndexed { index, item -> + NavigationBarItem( + selected = selectedIndex == index, + onClick = rememberDebouncedOnClick { onItemSelected(index) }, + icon = { + Icon( + imageVector = if (selectedIndex == index) { + item.selectedIcon + } else { + item.icon + }, + contentDescription = item.label + ) + }, + label = { + Text( + text = item.label, + style = MaterialTheme.typography.labelMedium + ) + }, + colors = NavigationBarItemDefaults.colors( + // Stitch: primary-container pill for active item + indicatorColor = MaterialTheme.colorScheme.primaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } +} diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogfab.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogfab.kt new file mode 100644 index 0000000..0aae112 --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogfab.kt @@ -0,0 +1,27 @@ +package com.example.catalogapp.feature.catalog.components + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick + +// ─── FAB — matches Stitch: filter tune icon, secondary-container ───────────── +@Composable +internal fun CatalogFab(onClick: () -> Unit) { + FloatingActionButton( + onClick = rememberDebouncedOnClick(onClick), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = RoundedCornerShape(16.dp) + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = "Filter" + ) + } +} diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogtopbar.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogtopbar.kt new file mode 100644 index 0000000..152a53a --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Catalogtopbar.kt @@ -0,0 +1,51 @@ +package com.example.catalogapp.feature.catalog.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick + +// ─── Top App Bar — matches Stitch: menu icon + title + search ───────────────── +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CatalogTopBar(onSearchClick: () -> Unit) { + TopAppBar( + navigationIcon = { + IconButton(rememberDebouncedOnClick { }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Menu", + tint = MaterialTheme.colorScheme.primary + ) + } + }, + title = { + Text( + text = "CatalogApp", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + }, + actions = { + IconButton(onClick = rememberDebouncedOnClick(onSearchClick)) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.primary + ) + } + }, + // Stitch: surface-container-low background + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) +} diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Categorychips.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Categorychips.kt new file mode 100644 index 0000000..6a3d995 --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Categorychips.kt @@ -0,0 +1,71 @@ +package com.example.catalogapp.feature.catalog.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +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.Modifier +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +// ─── Category chips — matches Stitch exactly ───────────────────────────────── +@Composable +internal fun CategoryChips( + categories: List, + selectedCategory: String, + onCategorySelected: (String) -> Unit +) { + val spacing = CatalogTheme.spacing + LazyRow( + contentPadding = PaddingValues(horizontal = spacing.md), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + modifier = Modifier.padding(vertical = spacing.sm) + ) { + items(categories) { category -> + CategoryChip( + label = category.replaceFirstChar { it.uppercase() }, + isSelected = category == selectedCategory, + onClick = rememberDebouncedOnClick { onCategorySelected(category) } + ) + } + } +} + +@Composable +private fun CategoryChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + val spacing = CatalogTheme.spacing + // Stitch: tertiary-container for active, surface-container-high for inactive + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.tertiaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val contentColor = if (isSelected) { + MaterialTheme.colorScheme.onTertiaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + color = containerColor, + contentColor = contentColor + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = spacing.lg, vertical = spacing.sm) + ) + } +} diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/ProductGridSkeleton.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/ProductGridSkeleton.kt new file mode 100644 index 0000000..ed5bf9d --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/ProductGridSkeleton.kt @@ -0,0 +1,30 @@ +package com.example.catalogapp.feature.catalog.components + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.catalogapp.core.designsystem.components.ProductCardSkeleton +import com.example.catalogapp.core.designsystem.theme.LocalSpacing + +private const val SKELETON_ITEM_COUNT = 4 +private const val GRID_COLUMN_COUNT = 2 + +@Composable +internal fun ProductGridSkeleton(modifier: Modifier = Modifier) { + val spacing = LocalSpacing.current + LazyVerticalGrid( + columns = GridCells.Fixed(GRID_COLUMN_COUNT), + modifier = modifier, + contentPadding = PaddingValues(spacing.md), + horizontalArrangement = spacedBy(spacing.sm), + verticalArrangement = spacedBy(spacing.sm) + ) { + items(SKELETON_ITEM_COUNT) { + ProductCardSkeleton() + } + } +} + diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Productcard.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Productcard.kt new file mode 100644 index 0000000..cb99d9b --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Productcard.kt @@ -0,0 +1,191 @@ +package com.example.catalogapp.feature.catalog.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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.FavoriteBorder +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.CatalogAsyncImage +import com.example.catalogapp.core.designsystem.components.ProductCardDefaults +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.core.designsystem.theme.Spacing +import com.example.catalogapp.domain.product.Product +import java.util.Locale + +private const val REVIEW_COUNT_MULTIPLIER = 100 + +// ─── Product card — matches Stitch exactly ──────────────────────────────────── +@Composable +internal fun ProductCard( + product: Product, + onClick: () -> Unit +) { + val spacing = CatalogTheme.spacing + Card( + modifier = Modifier + .fillMaxWidth() + .shadow( + elevation = 2.dp, + shape = RoundedCornerShape(12.dp), + ambientColor = Color.Black.copy(alpha = 0.04f), + spotColor = Color.Black.copy(alpha = 0.04f) + ) + .clickable { onClick() }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + // Stitch: surface-container-lowest for card background + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column { + ProductImageSection(product = product, spacing = spacing) + ProductInfoSection(product = product, spacing = spacing) + } + } +} + +// ─── Image with badge and wishlist overlay ──────────────────────────────────── +@Composable +private fun ProductImageSection( + product: Product, + spacing: Spacing +) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio( + ProductCardDefaults.IMAGE_ASPECT_WIDTH / + ProductCardDefaults.IMAGE_ASPECT_HEIGHT + ) + ) { + CatalogAsyncImage( + imageUrl = product.imageUrl, + contentDescription = product.title, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + + // Stitch: discount badge top-left, secondary-container color + Box( + modifier = Modifier + .padding(spacing.sm) + .align(Alignment.TopStart) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = spacing.sm, vertical = spacing.xs) + ) { + Text( + text = "15% OFF", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + // Stitch: wishlist button top-right, semi-transparent white + Box( + modifier = Modifier + .padding(spacing.sm) + .align(Alignment.TopEnd) + .size(32.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.FavoriteBorder, + contentDescription = "Add to wishlist", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +// ─── Title, rating, price ────────────────────────────────────────────────────── +@Composable +private fun ProductInfoSection( + product: Product, + spacing: Spacing +) { + Column( + modifier = Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.xs) + ) { + // Title — Stitch: body-md, 2 lines max + Text( + text = product.title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + RatingRow(rating = product.rating) + // Price — Stitch: title-lg style, primary color + Text( + // Explicit Locale.US: avoids ImplicitDefaultLocale and keeps digits + // consistent regardless of device locale (comma vs. dot separators). + text = "₹${String.format(Locale.US, "%.0f", product.price)}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } +} + +// ─── Rating row — matches Stitch: number first, filled star, count ──────────── +@Composable +private fun RatingRow(rating: Float) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Stitch: rating number in secondary color + Text( + // Explicit Locale.US: avoids ImplicitDefaultLocale. + text = String.format(Locale.US, "%.1f", rating), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary + ) + // Stitch: filled star icon in secondary color + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.secondary + ) + // Stitch: review count in outline-variant color + Text( + text = "(${(rating * REVIEW_COUNT_MULTIPLIER).toInt()})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outlineVariant + ) + } +} diff --git a/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Productgrid.kt b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Productgrid.kt new file mode 100644 index 0000000..fb91052 --- /dev/null +++ b/feature/catalog/src/main/java/com/example/catalogapp/feature/catalog/components/Productgrid.kt @@ -0,0 +1,39 @@ +package com.example.catalogapp.feature.catalog.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product + +// ─── Product grid ───────────────────────────────────────────────────────────── +@Composable +internal fun ProductGrid( + products: List, + onProductClick: (Int) -> Unit +) { + val spacing = CatalogTheme.spacing + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(spacing.md), + horizontalArrangement = Arrangement.spacedBy(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.md), + modifier = Modifier.fillMaxSize() + ) { + items( + items = products, + key = { product -> product.id } + ) { product -> + ProductCard( + product = product, + onClick = rememberDebouncedOnClick { onProductClick(product.id) } + ) + } + } +} diff --git a/feature/catalog/src/test/java/com/example/catalogapp/feature/catalog/CatalogViewModelTest.kt b/feature/catalog/src/test/java/com/example/catalogapp/feature/catalog/CatalogViewModelTest.kt new file mode 100644 index 0000000..4b782a4 --- /dev/null +++ b/feature/catalog/src/test/java/com/example/catalogapp/feature/catalog/CatalogViewModelTest.kt @@ -0,0 +1,206 @@ +package com.example.catalogapp.feature.catalog + +import com.example.catalogapp.domain.product.GetProductsUseCase +import com.example.catalogapp.domain.product.Product +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +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.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CatalogViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val getProductsUseCase: GetProductsUseCase = mockk() + private lateinit var viewModel: CatalogViewModel + + private val fakeProducts = listOf( + Product(1, "Backpack", 109.95, "Great bag", "men's clothing", "", 3.9f), + Product(2, "Gold Ring", 4500.0, "22K gold", "jewelery", "", 4.5f) + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state before loading has empty products and no error`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + + // Don't advance — check the default state before any coroutine runs + val initialState = viewModel.uiState.value + assertNull(initialState.error) + // Either loading or empty initially — both are valid before coroutines run + assertFalse(initialState.showError) + assertFalse(initialState.showProducts) + } + + @Test + fun `LoadProducts intent loads products successfully`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertNull(state.error) + assertEquals(2, state.products.size) + assertTrue(state.showProducts) + assertFalse(state.showLoading) + assertFalse(state.showError) + assertFalse(state.showEmpty) + } + + @Test + fun `SelectCategory intent filters products correctly`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + advanceUntilIdle() + + viewModel.processIntent(CatalogIntent.SelectCategory("jewelery")) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("jewelery", state.selectedCategory) + assertEquals(1, state.filteredProducts.size) + assertEquals("Gold Ring", state.filteredProducts[0].title) + } + + @Test + fun `SelectCategory All shows all products`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + advanceUntilIdle() + + viewModel.processIntent(CatalogIntent.SelectCategory("jewelery")) + viewModel.processIntent(CatalogIntent.SelectCategory("All")) + advanceUntilIdle() + + assertEquals(2, viewModel.uiState.value.filteredProducts.size) + } + + @Test + fun `error from use case updates state correctly`() = runTest { + every { getProductsUseCase() } returns kotlinx.coroutines.flow.flow { + throw RuntimeException("Network error") + } + viewModel = CatalogViewModel(getProductsUseCase) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertTrue(state.showError) + assertEquals("Network error", state.error) + assertTrue(state.products.isEmpty()) + } + + @Test + fun `categories derived from products include All plus unique categories`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + advanceUntilIdle() + + val categories = viewModel.uiState.value.categories + assertTrue(categories.contains("All")) + assertTrue(categories.contains("men's clothing")) + assertTrue(categories.contains("jewelery")) + assertEquals(3, categories.size) + } + + @Test + fun `ProductClicked intent sends NavigateToDetail effect`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + advanceUntilIdle() + + // Collect effect before sending intent + var receivedEffect: CatalogEffect? = null + val job = launch { + viewModel.effect.collect { receivedEffect = it } + } + + viewModel.processIntent(CatalogIntent.ProductClicked(productId = 1)) + advanceUntilIdle() + job.cancel() + + assertTrue(receivedEffect is CatalogEffect.NavigateToDetail) + assertEquals(1, (receivedEffect as CatalogEffect.NavigateToDetail).productId) + } + + @Test + fun `RefreshProducts intent re-fetches products via use case`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + advanceUntilIdle() + + viewModel.processIntent(CatalogIntent.RefreshProducts) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isRefreshing) + assertEquals(2, state.products.size) + // getProductsUseCase() should fire once on init, once on refresh — + // this is what actually distinguishes a real refresh from the old no-op + verify(exactly = 2) { getProductsUseCase() } + } + + @Test + fun `RefreshProducts intent surfaces error without wiping existing products`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + advanceUntilIdle() + + every { getProductsUseCase() } returns kotlinx.coroutines.flow.flow { + throw RuntimeException("Refresh failed") + } + viewModel.processIntent(CatalogIntent.RefreshProducts) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isRefreshing) + assertEquals("Refresh failed", state.error) + // Products from the successful initial load must survive a failed refresh + assertEquals(2, state.products.size) + } + @Test + fun `SearchClicked intent sends NavigateToSearch effect`() = runTest { + every { getProductsUseCase() } returns flowOf(fakeProducts) + viewModel = CatalogViewModel(getProductsUseCase) + advanceUntilIdle() + + viewModel.processIntent(CatalogIntent.SearchClicked) + advanceUntilIdle() + + var receivedEffect: CatalogEffect? = null + val job = launch { + viewModel.effect.collect { receivedEffect = it } + } + advanceUntilIdle() + job.cancel() + + assertTrue(receivedEffect is CatalogEffect.NavigateToSearch) + } +} diff --git a/feature/catalog/src/test/java/com/example/catalogapp/feature/catalog/ExampleUnitTest.kt b/feature/catalog/src/test/java/com/example/catalogapp/feature/catalog/ExampleUnitTest.kt new file mode 100644 index 0000000..cf88897 --- /dev/null +++ b/feature/catalog/src/test/java/com/example/catalogapp/feature/catalog/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.catalogapp.feature.catalog + +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/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..a95cf8b --- /dev/null +++ b/feature/detail/build.gradle.kts @@ -0,0 +1,62 @@ +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 + buildConfig = 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..6491562 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailContract.kt @@ -0,0 +1,34 @@ +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 RetryLoad : 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..d5f501f --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailScreen.kt @@ -0,0 +1,155 @@ +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.DetailSkeleton +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.feature.detail.components.DetailBody +import com.example.catalogapp.feature.detail.components.DetailBottomBar +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 -> DetailSkeleton( + modifier = Modifier + .fillMaxSize() + .padding(bottom = paddingValues.calculateBottomPadding()) + ) + uiState.showError -> CatalogErrorState( + message = uiState.error ?: "Unknown error", + onRetry = { onIntent(DetailIntent.RetryLoad) } + ) + + 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) } + ) + } + } +} + +@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 = {} + ) + } +} + +@Preview(showBackground = true, name = "Detail - Error") +@Composable +internal fun DetailErrorPreview() { + CatalogTheme { + DetailContent( + uiState = DetailUiState(error = "Product not found"), + 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..0a8cdee --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/DetailViewModel.kt @@ -0,0 +1,92 @@ +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() + private var currentProductId: Int? = null + + fun processIntent(intent: DetailIntent) { + when (intent) { + is DetailIntent.LoadProduct -> loadProduct(intent.productId) + is DetailIntent.RetryLoad -> currentProductId?.let { loadProduct(it) } + 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) { + currentProductId = productId + 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/DetailBody.kt b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailBody.kt new file mode 100644 index 0000000..3a69e86 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailBody.kt @@ -0,0 +1,48 @@ +package com.example.catalogapp.feature.detail.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.feature.detail.DetailIntent +import com.example.catalogapp.feature.detail.DetailUiState + +@Composable +internal 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() + } + } +} 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..65d8572 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailBottomBar.kt @@ -0,0 +1,128 @@ +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.components.rememberDebouncedOnClick +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 = rememberDebouncedOnClick(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 = rememberDebouncedOnClick(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..56381be --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailContentCard.kt @@ -0,0 +1,72 @@ +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 = 28 + +@Composable +internal fun DetailContentCard( + product: Product, + isDescriptionExpanded: Boolean, + onToggleDescription: () -> Unit, + modifier: Modifier = Modifier +) { + val spacing = CatalogTheme.spacing + // Stitch: -mt-12 (28dp 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) + // 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..6390763 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailHeroImage.kt @@ -0,0 +1,61 @@ +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.tooling.preview.Preview +import com.example.catalogapp.core.designsystem.components.CatalogAsyncImage +import com.example.catalogapp.core.designsystem.components.DetailHeroImageDefaults.GRADIENT_START_FRACTION +import com.example.catalogapp.core.designsystem.components.DetailHeroImageDefaults.HERO_ASPECT_HEIGHT +import com.example.catalogapp.core.designsystem.components.DetailHeroImageDefaults.HERO_ASPECT_WIDTH +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +@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) + ) { + CatalogAsyncImage( + imageUrl = imageUrl, + contentDescription = title, + modifier = Modifier.fillMaxSize() + ) + // 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..1ad31b1 --- /dev/null +++ b/feature/detail/src/main/java/com/example/catalogapp/feature/detail/components/DetailTopBar.kt @@ -0,0 +1,102 @@ +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.components.rememberDebouncedOnClick +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 = rememberDebouncedOnClick(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 = rememberDebouncedOnClick(onShareClick)) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + TopBarButton(onClick = rememberDebouncedOnClick { }) { + 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/feature/search/.gitignore b/feature/search/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/search/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts new file mode 100644 index 0000000..f998d99 --- /dev/null +++ b/feature/search/build.gradle.kts @@ -0,0 +1,79 @@ +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.search" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + 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) + implementation(libs.androidx.compose.ui.text) + 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/search/consumer-rules.pro b/feature/search/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/search/proguard-rules.pro b/feature/search/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature/search/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/search/src/androidTest/java/com/example/catalogapp/feature/search/ExampleInstrumentedTest.kt b/feature/search/src/androidTest/java/com/example/catalogapp/feature/search/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0215517 --- /dev/null +++ b/feature/search/src/androidTest/java/com/example/catalogapp/feature/search/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.catalogapp.feature.search + +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.search.test", appContext.packageName) + } +} diff --git a/feature/search/src/main/AndroidManifest.xml b/feature/search/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8bdb7e1 --- /dev/null +++ b/feature/search/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchContract.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchContract.kt new file mode 100644 index 0000000..49149bc --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchContract.kt @@ -0,0 +1,28 @@ +package com.example.catalogapp.feature.search + +import com.example.catalogapp.domain.product.Product + +data class SearchUiState( + val query: String = "", + val allProducts: List = emptyList(), + val filteredProducts: List = emptyList(), + val recentSearches: List = emptyList(), + val availableCategories: List = emptyList(), + val isLoading: Boolean = false, + val hasSearched: Boolean = false, + val error: String? = null +) + +sealed class SearchIntent { + data class QueryChanged(val query: String) : SearchIntent() + data object ClearQuery : SearchIntent() + data class ProductClicked(val productId: Int) : SearchIntent() + data class RecentSearchClicked(val query: String) : SearchIntent() + data object ClearRecentSearches : SearchIntent() + data object RetryLoad : SearchIntent() +} + +sealed class SearchEffect { + data class NavigateToDetail(val productId: Int) : SearchEffect() + data object DismissKeyboard : SearchEffect() +} diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchScreen.kt new file mode 100644 index 0000000..2685ebf --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchScreen.kt @@ -0,0 +1,202 @@ +package com.example.catalogapp.feature.search + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.catalogapp.core.designsystem.components.CatalogEmptyState +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.search.components.RecentSearchesSection +import com.example.catalogapp.feature.search.components.SearchBar +import com.example.catalogapp.feature.search.components.SearchErrorState +import com.example.catalogapp.feature.search.components.SearchPromptState +import com.example.catalogapp.feature.search.components.SearchResultsList + +@Composable +fun SearchScreen( + onNavigateToDetail: (Int) -> Unit, + viewModel: SearchViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is SearchEffect.NavigateToDetail -> onNavigateToDetail(effect.productId) + is SearchEffect.DismissKeyboard -> keyboardController?.hide() + } + } + } + + SearchContent( + uiState = uiState, + onIntent = viewModel::processIntent + ) + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SearchContent( + uiState: SearchUiState, + onIntent: (SearchIntent) -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + Box( + modifier = Modifier + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + SearchBar( + query = uiState.query, + onQueryChanged = { onIntent(SearchIntent.QueryChanged(it)) }, + onClear = { onIntent(SearchIntent.ClearQuery) } + ) + } + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + ) { + when { + uiState.error != null -> SearchErrorState( + message = uiState.error, + onRetry = { onIntent(SearchIntent.RetryLoad) } + ) + + uiState.isLoading -> CatalogLoadingState() + uiState.query.isBlank() -> { + if (uiState.recentSearches.isNotEmpty()) { + RecentSearchesSection( + recentSearches = uiState.recentSearches, + onRecentClicked = { onIntent(SearchIntent.RecentSearchClicked(it)) }, + onClearAll = { onIntent(SearchIntent.ClearRecentSearches) } + ) + } else { + SearchPromptState( + categories = uiState.availableCategories, + onCategoryClicked = { onIntent(SearchIntent.RecentSearchClicked(it)) } + ) + } + } + + uiState.hasSearched && uiState.filteredProducts.isEmpty() -> CatalogEmptyState( + message = "No products found." + ) + + else -> SearchResultsList( + products = uiState.filteredProducts, + onProductClicked = { onIntent(SearchIntent.ProductClicked(it)) } + ) + } + } + } +} + +@Preview(showBackground = true, name = "Content - error") +@Composable +internal fun SearchContentErrorPreview() { + CatalogTheme { + SearchContent( + uiState = SearchUiState(error = "Couldn't load products. Try again."), + onIntent = {} + ) + } +} + +@Preview(showBackground = true, name = "Content - loading") +@Composable +internal fun SearchContentLoadingPreview() { + CatalogTheme { + SearchContent( + uiState = SearchUiState(isLoading = true), + onIntent = {} + ) + } +} + +@Preview(showBackground = true, name = "Content - empty query, no recents") +@Composable +internal fun SearchContentEmptyQueryNoRecentsPreview() { + CatalogTheme { + SearchContent( + uiState = SearchUiState(), + onIntent = {} + ) + } +} + +@Preview(showBackground = true, name = "Content - empty query, with recents") +@Composable +internal fun SearchContentWithRecentsPreview() { + CatalogTheme { + SearchContent( + uiState = SearchUiState( + recentSearches = listOf("Silver earrings", "Running shoes") + ), + onIntent = {} + ) + } +} + +@Preview(showBackground = true, name = "Content - no results found") +@Composable +internal fun SearchContentNoResultsPreview() { + CatalogTheme { + SearchContent( + uiState = SearchUiState( + query = "xyz123", + hasSearched = true, + filteredProducts = emptyList() + ), + onIntent = {} + ) + } +} + +@Preview(showBackground = true, name = "Content - results found") +@Composable +internal fun SearchContentWithResultsPreview() { + CatalogTheme { + SearchContent( + uiState = SearchUiState( + query = "silver", + hasSearched = true, + filteredProducts = listOf( + Product( + id = 1, + title = "Sleek Silver Drop Earrings", + price = 120.0, + description = "Minimalist silver drop earrings.", + category = "Jewelry", + imageUrl = "", + rating = 4.8f + ) + ) + ), + onIntent = {} + ) + } +} diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchViewModel.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchViewModel.kt new file mode 100644 index 0000000..0569d5c --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/SearchViewModel.kt @@ -0,0 +1,120 @@ +package com.example.catalogapp.feature.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.catalogapp.domain.product.GetProductsUseCase +import com.example.catalogapp.domain.product.SearchProductsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val DEBOUNCE_MILLIS = 300L +private const val MAX_RECENT_SEARCHES = 5 +private const val LOAD_ERROR_MESSAGE = + "Couldn't load products. Check your connection and try again." + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val getProductsUseCase: GetProductsUseCase, + private val searchProductsUseCase: SearchProductsUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effect = Channel(Channel.BUFFERED) + val effect = _effect.receiveAsFlow() + + private val queryFlow = MutableStateFlow("") + + init { + loadCachedProducts() + observeQueryChanges() + } + + fun processIntent(intent: SearchIntent) { + when (intent) { + is SearchIntent.QueryChanged -> onQueryChanged(intent.query) + is SearchIntent.ClearQuery -> onQueryChanged("") + is SearchIntent.ProductClicked -> onProductClicked(intent.productId) + is SearchIntent.RecentSearchClicked -> onQueryChanged(intent.query) + is SearchIntent.ClearRecentSearches -> clearRecentSearches() + is SearchIntent.RetryLoad -> loadCachedProducts() + } + } + + private fun loadCachedProducts() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + getProductsUseCase() + .catch { exception -> + _uiState.update { + it.copy(isLoading = false, error = exception.message ?: LOAD_ERROR_MESSAGE) + } + } + .collect { products -> + _uiState.update { + it.copy( + allProducts = products, + availableCategories = products.map { p -> p.category }.distinct(), + isLoading = false, + error = null + ) + } + } + } + } + + @OptIn(FlowPreview::class) + private fun observeQueryChanges() { + viewModelScope.launch { + queryFlow + .debounce(DEBOUNCE_MILLIS) + .distinctUntilChanged() + .collect { query -> filterProducts(query) } + } + } + + private fun onQueryChanged(query: String) { + _uiState.update { it.copy(query = query) } + queryFlow.value = query + } + + private fun filterProducts(query: String) { + if (query.isBlank()) { + _uiState.update { it.copy(filteredProducts = emptyList(), hasSearched = false) } + return + } + val currentState = _uiState.value + val matches = searchProductsUseCase(currentState.allProducts, query) + _uiState.update { it.copy(filteredProducts = matches, hasSearched = true) } + if (matches.isNotEmpty()) addRecentSearch(query) + } + + private fun addRecentSearch(query: String) { + val updated = listOf(query) + _uiState.value.recentSearches.filterNot { it == query } + _uiState.update { it.copy(recentSearches = updated.take(MAX_RECENT_SEARCHES)) } + } + + private fun clearRecentSearches() { + _uiState.update { it.copy(recentSearches = emptyList()) } + } + + private fun onProductClicked(productId: Int) { + viewModelScope.launch { + _effect.send(SearchEffect.DismissKeyboard) + _effect.send(SearchEffect.NavigateToDetail(productId)) + } + } +} + diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/components/RecentSearchesSection.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/RecentSearchesSection.kt new file mode 100644 index 0000000..c91c331 --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/RecentSearchesSection.kt @@ -0,0 +1,109 @@ +package com.example.catalogapp.feature.search.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private const val CHIP_CORNER_RADIUS_PERCENT = 50 + +@Composable +internal fun RecentSearchesSection( + recentSearches: List, + onRecentClicked: (String) -> Unit, + onClearAll: () -> Unit +) { + if (recentSearches.isEmpty()) return + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "RECENT SEARCHES", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + TextButton(onClick = rememberDebouncedOnClick(onClearAll)) { + Text("Clear all") + } + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + recentSearches.forEach { search -> + RecentSearchChip(text = search, onClick = rememberDebouncedOnClick { onRecentClicked(search) }) + } + } + } +} + +@Composable +private fun RecentSearchChip(text: String, onClick: () -> Unit) { + AssistChip( + onClick = onClick, + label = { Text(text) }, + leadingIcon = { Icon(Icons.Default.History, contentDescription = null) }, + shape = androidx.compose.foundation.shape.RoundedCornerShape(CHIP_CORNER_RADIUS_PERCENT), + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) +} + +@Preview(showBackground = true, name = "RecentSearches - populated, wraps to 2 rows") +@Composable +internal fun RecentSearchesPopulatedPreview() { + CatalogTheme { + RecentSearchesSection( + recentSearches = listOf( + "Silver earrings", "Running shoes", "Smartwatch", + "Leather jacket", "Wireless headphones" + ), + onRecentClicked = {}, + onClearAll = {} + ) + } +} + +@Preview(showBackground = true, name = "RecentSearches - single item") +@Composable +internal fun RecentSearchesSingleItemPreview() { + CatalogTheme { + RecentSearchesSection( + recentSearches = listOf("Silver earrings"), + onRecentClicked = {}, + onClearAll = {} + ) + } +} + +@Preview(showBackground = true, name = "RecentSearches - empty (renders nothing)") +@Composable +internal fun RecentSearchesEmptyPreview() { + CatalogTheme { + RecentSearchesSection( + recentSearches = emptyList(), + onRecentClicked = {}, + onClearAll = {} + ) + } +} diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchBar.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchBar.kt new file mode 100644 index 0000000..79b8f20 --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchBar.kt @@ -0,0 +1,86 @@ +package com.example.catalogapp.feature.search.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private val SEARCH_BAR_CORNER_RADIUS = 28.dp + +@Composable +fun SearchBar( + query: String, + onQueryChanged: (String) -> Unit, + onClear: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = query, + onValueChange = onQueryChanged, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + shape = RoundedCornerShape(SEARCH_BAR_CORNER_RADIUS), + placeholder = { Text("Search products...", style = MaterialTheme.typography.bodyLarge) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = rememberDebouncedOnClick(onClear)) { + Icon(Icons.Default.Close, contentDescription = "Clear search") + } + } + }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { }), + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary + ) + ) +} + +@Preview(showBackground = true, name = "SearchBar - empty") +@Composable +internal fun SearchBarEmptyPreview() { + CatalogTheme { + SearchBar(query = "", onQueryChanged = {}, onClear = {}) + } +} + +@Preview(showBackground = true, name = "SearchBar - with query") +@Composable +internal fun SearchBarWithQueryPreview() { + CatalogTheme { + SearchBar(query = "silver earrings", onQueryChanged = {}, onClear = {}) + } +} diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchErrorState.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchErrorState.kt new file mode 100644 index 0000000..47d0cef --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchErrorState.kt @@ -0,0 +1,55 @@ +package com.example.catalogapp.feature.search.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.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.Button +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +@Composable +fun SearchErrorState(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 64.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = message, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = rememberDebouncedOnClick(onRetry)) { + Text("Retry") + } + } +} + +@Preview(showBackground = true, name = "ErrorState") +@Composable +internal fun SearchErrorStatePreview() { + CatalogTheme { + SearchErrorState( + message = "Couldn't load products. Check your connection and try again.", + onRetry = {} + ) + } +} diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchPromptState.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchPromptState.kt new file mode 100644 index 0000000..c1c944e --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchPromptState.kt @@ -0,0 +1,87 @@ +package com.example.catalogapp.feature.search.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AssistChip +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick +import com.example.catalogapp.core.designsystem.theme.CatalogTheme + +private val PROMPT_TOP_PADDING = 48.dp +private val PROMPT_ICON_SIZE = 64.dp + +@Composable +internal fun SearchPromptState( + categories: List, + onCategoryClicked: (String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = PROMPT_TOP_PADDING), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.size(PROMPT_ICON_SIZE) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Search for products", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Try a name, or browse a category below", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + categories.forEach { category -> + AssistChip( + onClick = rememberDebouncedOnClick { onCategoryClicked(category) }, + label = { Text(category) } + ) + } + } + } +} + +@Preview(showBackground = true, name = "SearchPromptState - with categories") +@Composable +internal fun SearchPromptStatePreview() { + CatalogTheme { + SearchPromptState( + categories = listOf("Electronics", "Jewelry", "Footwear", "Men's Clothing"), + onCategoryClicked = {} + ) + } +} + +@Preview(showBackground = true, name = "SearchPromptState - single category") +@Composable +internal fun SearchPromptStateSingleCategoryPreview() { + CatalogTheme { + SearchPromptState( + categories = listOf("Electronics"), + onCategoryClicked = {} + ) + } +} diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchResultCard.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchResultCard.kt new file mode 100644 index 0000000..d3b8b2e --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchResultCard.kt @@ -0,0 +1,126 @@ +package com.example.catalogapp.feature.search.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +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.draw.clip +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.CatalogAsyncImage +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product + +private val CARD_CORNER_RADIUS = 12.dp +private val IMAGE_SIZE = 96.dp + +@Composable +fun SearchResultCard(product: Product, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CARD_CORNER_RADIUS)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .clickable(onClick = onClick) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CatalogAsyncImage( + imageUrl = product.imageUrl, + contentDescription = product.title, + modifier = Modifier + .size(IMAGE_SIZE) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + Spacer(modifier = Modifier.width(16.dp)) + SearchResultInfo(product) + Icon(Icons.Default.ChevronRight, contentDescription = null) + } +} + +@Composable +private fun RowScope.SearchResultInfo(product: Product) { + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = product.category.uppercase(LocalLocale.current.platformLocale), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(16.dp) + ) + Text( + text = String.format( + LocalLocale.current.platformLocale, + "%.1f", + product.rating, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondaryContainer + ) + } + } + Text( + text = product.title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + String.format(LocalLocale.current.platformLocale, "$%.2f", product.price), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } +} + +@Preview(showBackground = true, name = "ResultCard - long title") +@Composable +internal fun SearchResultCardLongTitlePreview() { + CatalogTheme { + SearchResultCard( + product = Product( + id = 1, + title = "Extremely Long Product Title That Might Wrap To Two Lines", + price = 999.99, + description = "A product with a very long name to test text wrapping.", + category = "Electronics", + imageUrl = "", + rating = 4.5f + ), + onClick = {} + ) + } +} diff --git a/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchResultsList.kt b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchResultsList.kt new file mode 100644 index 0000000..cd85f4b --- /dev/null +++ b/feature/search/src/main/java/com/example/catalogapp/feature/search/components/SearchResultsList.kt @@ -0,0 +1,89 @@ +package com.example.catalogapp.feature.search.components + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.catalogapp.core.designsystem.components.rememberDebouncedOnClick +import com.example.catalogapp.core.designsystem.theme.CatalogTheme +import com.example.catalogapp.domain.product.Product + +@Composable +fun SearchResultsList( + products: List, + onProductClicked: (Int) -> Unit +) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(products, key = { it.id }) { product -> + SearchResultCard( + product = product, + onClick = rememberDebouncedOnClick { onProductClicked(product.id) }) + } + } +} + +@Preview(showBackground = true, name = "ResultsList - multiple items") +@Composable +internal fun SearchResultsListPreview() { + CatalogTheme { + SearchResultsList( + products = listOf( + Product( + id = 1, + title = "Sleek Silver Drop Earrings", + price = 120.0, + description = "Minimalist silver drop earrings.", + category = "Jewelry", + imageUrl = "", + rating = 4.8f + ), + Product( + id = 2, + title = "Nitro Pulse Runner Pro", + price = 185.0, + description = "High-performance running shoes.", + category = "Footwear", + imageUrl = "", + rating = 4.9f + ), + Product( + id = 3, + title = "Aura Smartwatch Gen-4", + price = 349.99, + description = "Fitness-focused smartwatch.", + category = "Electronics", + imageUrl = "", + rating = 4.7f + ) + ), + onProductClicked = {} + ) + } +} + +@Preview(showBackground = true, name = "ResultsList - single item") +@Composable +internal fun SearchResultsListSingleItemPreview() { + CatalogTheme { + SearchResultsList( + products = listOf( + Product( + id = 1, + title = "Sleek Silver Drop Earrings", + price = 120.0, + description = "Minimalist silver drop earrings.", + category = "Jewelry", + imageUrl = "", + rating = 4.8f + ) + ), + onProductClicked = {} + ) + } +} diff --git a/feature/search/src/test/java/com/example/catalogapp/feature/search/ExampleUnitTest.kt b/feature/search/src/test/java/com/example/catalogapp/feature/search/ExampleUnitTest.kt new file mode 100644 index 0000000..fdb0736 --- /dev/null +++ b/feature/search/src/test/java/com/example/catalogapp/feature/search/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.catalogapp.feature.search + +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/feature/search/src/test/java/com/example/catalogapp/feature/search/SearchViewModelTest.kt b/feature/search/src/test/java/com/example/catalogapp/feature/search/SearchViewModelTest.kt new file mode 100644 index 0000000..2b45bf2 --- /dev/null +++ b/feature/search/src/test/java/com/example/catalogapp/feature/search/SearchViewModelTest.kt @@ -0,0 +1,260 @@ +package com.example.catalogapp.feature.search + +import com.example.catalogapp.domain.product.GetProductsUseCase +import com.example.catalogapp.domain.product.Product +import com.example.catalogapp.domain.product.SearchProductsUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class SearchViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var getProductsUseCase: GetProductsUseCase + private lateinit var searchProductsUseCase: SearchProductsUseCase + private lateinit var viewModel: SearchViewModel + + private val sampleProducts = listOf( + Product( + id = 1, title = "Silver Earrings", price = 120.0, + description = "d", category = "Jewelry", imageUrl = "", rating = 4.8f + ), + Product( + id = 2, title = "Running Shoes", price = 185.0, + description = "d", category = "Footwear", imageUrl = "", rating = 4.9f + ) + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + getProductsUseCase = mockk() + searchProductsUseCase = mockk() + coEvery { getProductsUseCase() } returns flowOf(sampleProducts) + viewModel = SearchViewModel(getProductsUseCase, searchProductsUseCase) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // --- Loading --- + + @Test + fun `cached products load successfully on init`() = runTest(testDispatcher) { + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(2, state.allProducts.size) + assertTrue(!state.isLoading) + assertNull(state.error) + } + + @Test + fun `available categories are derived and deduplicated from loaded products`() = + runTest(testDispatcher) { + testDispatcher.scheduler.advanceUntilIdle() + + val categories = viewModel.uiState.value.availableCategories + assertEquals(2, categories.size) + assertTrue(categories.contains("Jewelry")) + assertTrue(categories.contains("Footwear")) + } + + // --- Debounce — proves timing, delegates matching to the mocked use case --- + + @Test + fun `debounce - use case only invoked after 300ms of no new input`() = runTest(testDispatcher) { + every { searchProductsUseCase(any(), any()) } returns listOf(sampleProducts[0]) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.QueryChanged("s")) + testDispatcher.scheduler.advanceTimeBy(100) + viewModel.processIntent(SearchIntent.QueryChanged("si")) + testDispatcher.scheduler.advanceTimeBy(100) + viewModel.processIntent(SearchIntent.QueryChanged("silver")) + + assertTrue(viewModel.uiState.value.filteredProducts.isEmpty()) + + testDispatcher.scheduler.advanceTimeBy(301) + assertEquals(1, viewModel.uiState.value.filteredProducts.size) + } + + @Test + fun `rapid clear then retype does not leak a stale filter result`() = runTest(testDispatcher) { + every { searchProductsUseCase(any(), "shoes") } returns listOf(sampleProducts[1]) + every { searchProductsUseCase(any(), "silver") } returns listOf(sampleProducts[0]) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.QueryChanged("shoes")) + testDispatcher.scheduler.advanceTimeBy(50) + viewModel.processIntent(SearchIntent.ClearQuery) + testDispatcher.scheduler.advanceTimeBy(50) + viewModel.processIntent(SearchIntent.QueryChanged("silver")) + testDispatcher.scheduler.advanceTimeBy(301) + + val state = viewModel.uiState.value + assertEquals(1, state.filteredProducts.size) + assertEquals("Silver Earrings", state.filteredProducts.first().title) + } + + // --- State transitions around the use case boundary --- + + @Test + fun `empty query resets filtered results and hasSearched without calling use case`() = + runTest(testDispatcher) { + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.ClearQuery) + testDispatcher.scheduler.advanceTimeBy(301) + + val state = viewModel.uiState.value + assertTrue(state.filteredProducts.isEmpty()) + assertTrue(!state.hasSearched) + } + + @Test + fun `no matches from use case sets hasSearched true with empty results`() = + runTest(testDispatcher) { + every { searchProductsUseCase(any(), any()) } returns emptyList() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.QueryChanged("nonexistent")) + testDispatcher.scheduler.advanceTimeBy(301) + + val state = viewModel.uiState.value + assertTrue(state.filteredProducts.isEmpty()) + assertTrue(state.hasSearched) + } + + // --- Recent searches --- + + @Test + fun `successful match adds query to recent searches`() = runTest(testDispatcher) { + every { searchProductsUseCase(any(), any()) } returns listOf(sampleProducts[1]) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.QueryChanged("shoes")) + testDispatcher.scheduler.advanceTimeBy(301) + + assertTrue(viewModel.uiState.value.recentSearches.contains("shoes")) + } + + @Test + fun `query with no matches is not added to recent searches`() = runTest(testDispatcher) { + every { searchProductsUseCase(any(), any()) } returns emptyList() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.QueryChanged("nonexistent")) + testDispatcher.scheduler.advanceTimeBy(301) + + assertTrue(viewModel.uiState.value.recentSearches.isEmpty()) + } + + @Test + fun `duplicate recent search moves to front instead of duplicating`() = + runTest(testDispatcher) { + every { searchProductsUseCase(any(), "shoes") } returns listOf(sampleProducts[1]) + every { searchProductsUseCase(any(), "silver") } returns listOf(sampleProducts[0]) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.QueryChanged("shoes")) + testDispatcher.scheduler.advanceTimeBy(301) + viewModel.processIntent(SearchIntent.QueryChanged("silver")) + testDispatcher.scheduler.advanceTimeBy(301) + viewModel.processIntent(SearchIntent.QueryChanged("shoes")) + testDispatcher.scheduler.advanceTimeBy(301) + + val recents = viewModel.uiState.value.recentSearches + assertEquals("shoes", recents.first()) + assertEquals(1, recents.count { it == "shoes" }) + } + + @Test + fun `clear recent searches empties the list`() = runTest(testDispatcher) { + every { searchProductsUseCase(any(), any()) } returns listOf(sampleProducts[1]) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.QueryChanged("shoes")) + testDispatcher.scheduler.advanceTimeBy(301) + viewModel.processIntent(SearchIntent.ClearRecentSearches) + + assertTrue(viewModel.uiState.value.recentSearches.isEmpty()) + } + + @Test + fun `recent search click re-triggers filter for that query`() = runTest(testDispatcher) { + every { searchProductsUseCase(any(), "silver") } returns listOf(sampleProducts[0]) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.processIntent(SearchIntent.RecentSearchClicked("silver")) + testDispatcher.scheduler.advanceTimeBy(301) + + val state = viewModel.uiState.value + assertEquals("silver", state.query) + assertEquals(1, state.filteredProducts.size) + } + + // --- Effects --- + + @Test + fun `product click emits DismissKeyboard then NavigateToDetail effects`() = + runTest(testDispatcher) { + val effects = mutableListOf() + val job = launch { viewModel.effect.collect { effects.add(it) } } + + viewModel.processIntent(SearchIntent.ProductClicked(1)) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(SearchEffect.DismissKeyboard, effects[0]) + assertEquals(SearchEffect.NavigateToDetail(1), effects[1]) + job.cancel() + } + + // --- Error / retry --- + + @Test + fun `load failure sets error state and clears loading`() = runTest(testDispatcher) { + coEvery { getProductsUseCase() } returns flow { throw Exception("network down") } + val vm = SearchViewModel(getProductsUseCase, searchProductsUseCase) + testDispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.value + assertTrue(state.error != null) + assertTrue(!state.isLoading) + } + + @Test + fun `retry after failure reloads products successfully`() = runTest(testDispatcher) { + coEvery { getProductsUseCase() } returns flow { throw Exception("network down") } + val vm = SearchViewModel(getProductsUseCase, searchProductsUseCase) + testDispatcher.scheduler.advanceUntilIdle() + assertTrue(vm.uiState.value.error != null) + + coEvery { getProductsUseCase() } returns flowOf(sampleProducts) + vm.processIntent(SearchIntent.RetryLoad) + testDispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.value + assertNull(state.error) + assertEquals(2, state.allProducts.size) + } +} + diff --git a/gradle.properties b/gradle.properties index 2d6942d..edaaf1c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,3 +13,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # org.gradle.parallel=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5025de..48ddfd6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "9.1.1" coreKtx = "1.15.0" +coil = "2.7.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" @@ -11,7 +12,8 @@ composeBom = "2024.09.00" coroutines = "1.9.0" retrofit = "2.11.0" okhttp = "4.12.0" -room = "2.6.1" +robolectric = "4.14.1" +room = "2.8.4" lifecycle = "2.8.7" navigation = "2.8.5" mockk = "1.13.13" @@ -19,9 +21,19 @@ appcompat = "1.7.1" 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" +uiText = "1.11.4" +workmanager = "2.9.1" +hilt-work = "1.2.0" +gson = "2.11.0" +workTesting = "2.11.2" +coreKtxVersion = "1.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "workTesting" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -48,6 +60,24 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version material = { group = "com.google.android.material", name = "material", version.ref = "material" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version = "1.7.8" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "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" } +androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text", version.ref = "uiText" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" } +hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hilt-work" } +hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hilt-work" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtxVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -57,4 +87,6 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/settings.gradle.kts b/settings.gradle.kts index e1ea7e3..5691d6c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,11 +25,10 @@ dependencyResolutionManagement { rootProject.name = "CatalogApp" include(":app") include(":core:network") -include(":core:database") -include(":core:designsystem") include(":core:common") -include(":data:product") include(":domain:product") +include(":data:product") +include(":core:designsystem") include(":feature:catalog") include(":feature:detail") include(":feature:search")