diff --git a/app/build.gradle b/app/build.gradle index 1cdd96f..e4cbeb0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.kotlinAndroid) alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.kapt) + alias(libs.plugins.kotlinCompose) } android { @@ -34,15 +35,30 @@ android { } buildFeatures { viewBinding true + compose = true } } +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_reports") + metricsDestination = layout.buildDirectory.dir("compose_metrics") +} + + dependencies { implementation project(":common:di") implementation project(":common:formatters") implementation project(":common:ui") implementation project(":common:data:products") implementation project(":common:data:promo") + implementation(platform("androidx.compose:compose-bom:2025.05.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.activity:activity-compose:1.10.1") + implementation("io.coil-kt:coil-compose:2.7.0") + debugImplementation("androidx.compose.ui:ui-tooling") + implementation("androidx.compose.ui:ui-tooling-preview") implementation libs.core.ktx implementation libs.appcompat @@ -64,6 +80,8 @@ dependencies { implementation libs.androidx.datastore.preferences implementation libs.dagger + implementation libs.androidx.runtime + implementation libs.androidx.foundation.layout kapt libs.daggerCompiler testImplementation libs.junit diff --git a/app/src/main/java/ru/otus/marketsample/common/Compose.kt b/app/src/main/java/ru/otus/marketsample/common/Compose.kt new file mode 100644 index 0000000..02e5705 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/common/Compose.kt @@ -0,0 +1,20 @@ +package ru.otus.marketsample.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp + +@Composable +fun Loader(modifier: Modifier = Modifier) { + Box(modifier = modifier) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = colorResource(ru.otus.common.ui.R.color.teal_700), + strokeWidth = 3.dp + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/products/compose/Products.kt b/app/src/main/java/ru/otus/marketsample/products/compose/Products.kt new file mode 100644 index 0000000..1f86161 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/compose/Products.kt @@ -0,0 +1,191 @@ +package ru.otus.marketsample.products.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import ru.otus.marketsample.products.feature.ProductState +import ru.otus.marketsample.R +import ru.otus.marketsample.products.feature.ProductsScreenState +import kotlin.String + + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("NonSkippableComposable") +@Composable +fun ProductsList( + state: ProductsScreenState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onClick: (String) -> Unit +) { + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = onRefresh, + ) { + LazyColumn(modifier = modifier) { + items( + items = state.productListState, + key = { it.id }) { product -> + ProductItem(productState = product, onClick = { id -> + onClick(id) + }) + } + } + } +} + +@Composable +fun ProductItem( + productState: ProductState, + modifier: Modifier = Modifier, + onClick: (String) -> Unit +) { + Row( + modifier = modifier + .background(color = Color.White) + .clickable( + onClick = { onClick(productState.id) } + ) + .padding(horizontal = 16.dp, vertical = 24.dp) + .height(130.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + ) { + val isPreview = + androidx.compose.ui.platform.LocalInspectionMode.current + + if (isPreview) {//оставил для дебага, в рабочем коде бы убрал такое + Image( + painter = painterResource(R.drawable.ic_launcher_background), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + ) + } else { + AsyncImage( + model = productState.image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + ) + } + if (productState.hasDiscount) { + Box( + modifier = Modifier + .align(alignment = Alignment.TopEnd) + .padding(vertical = 4.dp, horizontal = 10.dp) + .padding(8.dp) + .background( + brush = Brush.linearGradient( + listOf(Color(0xFFCE93D8), Color(0xFF7E57C2)) + ), + shape = RoundedCornerShape(40.dp) + ) + .border(2.dp, Color.White, RoundedCornerShape(40.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = productState.discount, + fontSize = 14.sp, + color = Color.White, + modifier = Modifier + ) + } + } + } + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = productState.name, + maxLines = 2, + fontSize = 18.sp, + fontFamily = FontFamily.SansSerif, + color = Color.Black, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(horizontal = 12.dp) + + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.price_with_arg, productState.price), + fontSize = 16.sp, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + color = colorResource(ru.otus.common.ui.R.color.purple_500), + modifier = Modifier + .align(Alignment.BottomEnd) + .background( + brush = Brush.linearGradient( + colors = listOf( + Color(0xFFFFF3E0), + Color(0xFFFFF3E0) + ) + ), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + } +} + +@Preview +@Composable +fun ProductItemPreview() { + ProductItem( + ProductState( + id = "11", + name = "Персиковый кранч", + image = "https://otus-android.github.io/static/compose-hw1/img/product01.webp", + price = "1213", + hasDiscount = true, + discount = "12 %", + ), + onClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt index 88f7ec0..7c21f84 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt @@ -6,19 +6,17 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.launch import ru.otus.marketsample.MarketSampleApp import ru.otus.marketsample.R +import ru.otus.marketsample.common.Loader import ru.otus.marketsample.databinding.FragmentProductListBinding -import ru.otus.marketsample.products.feature.adapter.ProductsAdapter +import ru.otus.marketsample.products.compose.ProductsList import ru.otus.marketsample.products.feature.di.DaggerProductListComponent import javax.inject.Inject @@ -55,61 +53,28 @@ class ProductListFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.recyclerView.adapter = ProductsAdapter( - onItemClicked = { productId -> - requireActivity().findNavController(R.id.nav_host_activity_main) - .navigate( - resId = R.id.action_main_to_details, - args = bundleOf("productId" to productId), - ) - } - ) - binding.recyclerView.layoutManager = LinearLayoutManager(context) - - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh() - } - - subscribeUI() - } - - private fun subscribeUI() { - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - viewModel.state.collect { state -> - when { - state.isLoading -> showLoading() - state.hasError -> { - Toast.makeText( - requireContext(), - "Error wile loading data", - Toast.LENGTH_SHORT - ).show() - - viewModel.errorHasShown() - } - - else -> showProductList(productListState = state.productListState) - } - } - } + binding.productsComposeView.setContent { + val state by viewModel.state.collectAsState() + + when { + state.isLoading -> Loader() + state.hasError -> Toast.makeText( + requireContext(), + "Error wile loading data", + Toast.LENGTH_SHORT + ).show() + else -> ProductsList(state, onRefresh = { viewModel.refresh()}, + onClick = { productId -> + requireActivity().findNavController(R.id.nav_host_activity_main) + .navigate( + resId = R.id.action_main_to_details, + args = bundleOf("productId" to productId), + ) + }) } } } - private fun showProductList(productListState: List) { - binding.progress.visibility = View.GONE - binding.recyclerView.visibility = View.VISIBLE - (binding.recyclerView.adapter as ProductsAdapter).submitList(productListState) - binding.swipeRefreshLayout.isRefreshing = false - } - - private fun showLoading() { - binding.progress.visibility = View.VISIBLE - binding.recyclerView.visibility = View.GONE - } - override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/compose/Promo.kt b/app/src/main/java/ru/otus/marketsample/products/feature/compose/Promo.kt new file mode 100644 index 0000000..73e1e22 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/feature/compose/Promo.kt @@ -0,0 +1,117 @@ +package ru.otus.marketsample.products.feature.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +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.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import ru.otus.marketsample.R +import ru.otus.marketsample.promo.feature.PromoScreenState +import ru.otus.marketsample.promo.feature.PromoState + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("NonSkippableComposable") +@Composable +fun PromoList( + state: PromoScreenState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, +) { + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = onRefresh, + ) { + LazyColumn(modifier = modifier) { + items( + items = state.promoListState, + key = { it.id }) { promo -> + PromoItem(promoState = promo) + } + } + } +} + +@Composable +fun PromoItem( + promoState: PromoState, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background(color = Color.White) + .padding(10.dp) + .height(250.dp) + ) { + val isPreview = + LocalInspectionMode.current + + if (isPreview) {//оставил для дебага, в рабочем коде бы убрал такое + Image( + painter = painterResource(R.drawable.ic_launcher_background), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + ) + } else { + AsyncImage( + model = promoState.image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + ) + } + + Column(modifier = Modifier + .align(Alignment.BottomStart) + .padding(10.dp)) { + + Text( + text = promoState.name, + fontSize = 24.sp, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = promoState.description, + fontSize = 14.sp, + color = Color.White + ) + } + } +} + +@Preview +@Composable +fun ProductItemPreview() { + PromoItem( + PromoState( + id = "11", + name = "Здоровый старт", + image = "https://otus-android.github.io/static/compose-hw1/img/product01.webp", + description = "Blabla" + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt index 2e4f533..bea63f3 100644 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt @@ -6,15 +6,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.launch import ru.otus.common.di.findDependencies +import ru.otus.marketsample.common.Loader import ru.otus.marketsample.databinding.FragmentPromoListBinding +import ru.otus.marketsample.products.feature.compose.PromoList import ru.otus.marketsample.promo.feature.adapter.PromoAdapter import ru.otus.marketsample.promo.feature.di.DaggerPromoComponent import javax.inject.Inject @@ -52,53 +51,21 @@ class PromoListFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.recyclerView.adapter = adapter - binding.recyclerView.layoutManager = LinearLayoutManager(context) - - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh() - } - - subscribeUI() - } - - private fun subscribeUI() { - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - viewModel.state.collect { state -> - when { - state.isLoading -> showLoading() - state.hasError -> { - Toast.makeText( - requireContext(), - "Error wile loading data", - Toast.LENGTH_SHORT - ).show() - - viewModel.errorHasShown() - } - - else -> showPromoList(promoListState = state.promoListState) - } - } - } + binding.promoComposeView.setContent { + val state by viewModel.state.collectAsState() + + when { + state.isLoading -> Loader() + state.hasError -> Toast.makeText( + requireContext(), + "Error wile loading data", + Toast.LENGTH_SHORT + ).show() + else -> PromoList(state, onRefresh = { viewModel.refresh()}) } } } - private fun showPromoList(promoListState: List) { - binding.progress.visibility = View.GONE - binding.recyclerView.visibility = View.VISIBLE - adapter.submitList(promoListState) - binding.swipeRefreshLayout.isRefreshing = false - } - - private fun showLoading() { - binding.progress.visibility = View.VISIBLE - binding.recyclerView.visibility = View.GONE - } - override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/res/layout/fragment_product_list.xml b/app/src/main/res/layout/fragment_product_list.xml index 7dc8ee3..3452393 100644 --- a/app/src/main/res/layout/fragment_product_list.xml +++ b/app/src/main/res/layout/fragment_product_list.xml @@ -4,31 +4,10 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - + android:layout_height="match_parent" /> diff --git a/app/src/main/res/layout/fragment_promo_list.xml b/app/src/main/res/layout/fragment_promo_list.xml index da5a492..3080425 100644 --- a/app/src/main/res/layout/fragment_promo_list.xml +++ b/app/src/main/res/layout/fragment_promo_list.xml @@ -2,35 +2,12 @@ + android:layout_height="match_parent"> - - - - - - + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad9bf92..6f28db5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,8 @@ serializationJson = "1.9.0" androidXDatastore = "1.1.7" kotlinx-coroutines-rx2 = "1.7.3" dagger = "2.57.1" +runtime = "1.11.1" +foundationLayout = "1.11.1" [libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -54,6 +56,8 @@ kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serializat kotlinx-coroutines-rx2 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx2", version.ref = "kotlinx-coroutines-rx2" } dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } daggerCompiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } +androidx-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } +androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } [bundles] network = ["okhttp", "okhttp-logging-interceptor", "retrofit", "retrofit-converter-gson"] @@ -64,4 +68,5 @@ kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinCompose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }