From 78ec5fcc21cb65845ede327014d6460035b4f9c6 Mon Sep 17 00:00:00 2001 From: Hp Date: Sun, 20 Jul 2025 10:00:41 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20fragment/view=20=D0=BD=D0=B0=20compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 14 + .../java/ru/otus/marketsample/MainFragment.kt | 78 ++++- .../details/feature/DetailsFragment.kt | 224 +++++++++----- .../products/feature/ProductListFragment.kt | 287 ++++++++++++++---- .../promo/feature/PromoListFragment.kt | 207 +++++++++---- build.gradle | 1 + gradle/libs.versions.toml | 18 +- gradle/wrapper/gradle-wrapper.properties | 4 +- local.properties | 8 +- 9 files changed, 639 insertions(+), 202 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a66984a..fbce546 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.compose.compiler) } android { @@ -35,6 +36,12 @@ android { buildFeatures { viewBinding true } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.15" + } } dependencies { @@ -62,6 +69,13 @@ dependencies { implementation libs.kotlinx.serializationJson implementation libs.androidx.datastore implementation libs.androidx.datastore.preferences + implementation platform(libs.compose.bom) + implementation libs.material3 + implementation libs.compose.preview + implementation libs.compose.activity + implementation libs.compose.viewmodel + debugImplementation libs.androidx.ui.tooling + implementation libs.coil.compose implementation libs.dagger kapt libs.daggerCompiler diff --git a/app/src/main/java/ru/otus/marketsample/MainFragment.kt b/app/src/main/java/ru/otus/marketsample/MainFragment.kt index b6dc47b..454f22e 100644 --- a/app/src/main/java/ru/otus/marketsample/MainFragment.kt +++ b/app/src/main/java/ru/otus/marketsample/MainFragment.kt @@ -38,4 +38,80 @@ class MainFragment : Fragment() { super.onDestroyView() _binding = null } -} \ No newline at end of file +} + +//@Serializable +//sealed class Screen( +// @StringRes val title: Int, +// @DrawableRes val icon: Int +//) { +// @Serializable +// object ProductList : Screen( +// title = R.string.title_products, +// icon = ru.otus.common.ui.R.drawable.ic_list +// ) +// +// @Serializable +// object PromoList : Screen( +// title = R.string.title_promo, +// icon = ru.otus.common.ui.R.drawable.ic_discount +// ) +// +// @Serializable +// data class ProductDetails(val id: String) +//} +// +//@Composable +//fun MainScreen() { +// val navController = rememberNavController() +// val bottomNavigationItems = listOf(ProductList, PromoList) +// val navBackStackEntry by navController.currentBackStackEntryAsState() +// val currentRoute = navBackStackEntry?.destination +// +// Scaffold( +// bottomBar = { +// NavigationBar { +// bottomNavigationItems.forEach { navigationItem -> +// NavigationBarItem( +// icon = { +// Icon( +// painter = painterResource(navigationItem.icon), +// contentDescription = null +// ) +// }, +// label = { +// Text(stringResource(navigationItem.title)) +// }, +// selected = currentRoute == navigationItem, +// onClick = { +// navController.navigate(navigationItem) +// } +// ) +// } +// } +// }, +// ) { padding -> +// NavHost( +// navController, +// startDestination = ProductList, +// modifier = Modifier.padding(padding) +// ) { +// composable { +// ProductListScreen( +// navigateToDetails = { productId -> +// navController.navigate( +// route = ProductDetails(productId) +// ) +// } +// ) +// } +// composable { +// PromoListScreen() +// } +// composable { backStackEntry -> +// val productId: String = backStackEntry.toRoute() +// DetailsScreen(productId = productId) +// } +// } +// } +//} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsFragment.kt b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsFragment.kt index e23c57e..608aa5f 100644 --- a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsFragment.kt +++ b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsFragment.kt @@ -5,25 +5,51 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast +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.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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import coil.load +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import kotlinx.coroutines.launch import ru.otus.common.di.findDependencies -import ru.otus.marketsample.details.feature.di.DaggerDetailsComponent import ru.otus.marketsample.R -import ru.otus.marketsample.databinding.FragmentDetailsBinding +import ru.otus.marketsample.details.feature.di.DaggerDetailsComponent import javax.inject.Inject class DetailsFragment : Fragment() { - private var _binding: FragmentDetailsBinding? = null - private val binding get() = _binding!! - @Inject lateinit var factory: DetailsViewModelFactory @@ -48,78 +74,140 @@ class DetailsFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - _binding = FragmentDetailsBinding.inflate(inflater, container, false) - return binding.root + ): View? { + return ComposeView(requireContext()).apply { + setContent { + DetailsScreen(viewModel) + } + } } +} - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } +@Composable +private fun DetailsScreen(viewModel: DetailsViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - subscribeUI() - } + DetailsContent( + state = state, + onErrorShown = viewModel::errorHasShown + ) +} - 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 -> showProduct(detailsState = state.detailsState) - } - } - } +@Composable +private fun DetailsContent( + state: DetailsScreenState, + onErrorShown: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color(0xFFFFFFFF)) + ) { + when { + state.isLoading -> LoadingState(Modifier.align(Alignment.Center)) + state.hasError -> { + ErrorState(modifier = Modifier.align(Alignment.BottomCenter), onErrorShown) } + + else -> ProductState(state.detailsState) } } +} - private fun showLoading() { - hideAll() - binding.progress.visibility = View.VISIBLE - } +@Composable +fun LoadingState(modifier: Modifier) { + CircularProgressIndicator(modifier = modifier) +} - private fun showProduct(detailsState: DetailsState) { - hideAll() - binding.image.load(detailsState.image) - binding.image.visibility = View.VISIBLE +@Composable +fun ErrorState(modifier: Modifier, onErrorShown: () -> Unit) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } - binding.name.text = detailsState.name - binding.name.visibility = View.VISIBLE + LaunchedEffect(Unit) { + scope.launch { + snackbarHostState.showSnackbar(message = "Error while loading data") + } + onErrorShown() + } - binding.price.text = getString(R.string.price_with_arg, detailsState.price) - binding.price.visibility = View.VISIBLE + SnackbarHost( + hostState = snackbarHostState, + modifier = modifier + ) +} +@Composable +private fun ProductState(detailsState: DetailsState) { + val priceBrush = Brush.linearGradient( + start = Offset(0f, Float.POSITIVE_INFINITY), + end = Offset(Float.POSITIVE_INFINITY, 0f), + colors = listOf(Color(0xFF6200EE), Color(0xFFBB86FC)) + ) + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Top + ) { + AsyncImage( + model = detailsState.image, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + contentScale = ContentScale.Crop + ) + Text( + text = detailsState.name, + color = Color(0xFF000000), + fontSize = 24.sp + ) if (detailsState.hasDiscount) { - binding.promo.visibility = View.VISIBLE - binding.promo.text = detailsState.discount - } else { - binding.promo.visibility = View.GONE + Text( + text = detailsState.discount, + modifier = Modifier + .align(Alignment.End) + .clip(RoundedCornerShape(20.dp, 2.dp, 10.dp, 20.dp)) + .background(priceBrush) + .padding(horizontal = 10.dp), + color = Color(0xFFFFFFFF), + fontSize = 20.sp + ) + } + Text( + text = stringResource(R.string.price_with_arg, detailsState.price), + modifier = Modifier + .align(Alignment.End) + .padding(14.dp), + color = Color(0xFF6200EE), + fontSize = 18.sp + ) + Spacer(Modifier.size(10.dp)) + Button( + onClick = { }, + modifier = Modifier + .padding(14.dp) + .align(Alignment.End) + ) { + Text( + text = "Add to Cart", + fontSize = 18.sp + ) } - - binding.addToCart.visibility = View.VISIBLE } +} - private fun hideAll() { - binding.progress.visibility = View.GONE - binding.image.visibility = View.GONE - binding.name.visibility = View.GONE - binding.price.visibility = View.GONE - binding.progress.visibility = View.GONE - binding.addToCart.visibility = View.GONE - } +@Preview +@Composable +private fun PreviewDetailsContent() { + DetailsContent( + state = DetailsScreenState( + detailsState = DetailsState( + name = "Product Name", + price = "244", + hasDiscount = true, + discount = "-50%" + ) + ), onErrorShown = {}) } 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 7129748..d52e905 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 @@ -5,28 +5,59 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.rememberLazyListState +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.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.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +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 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.launch +import coil.compose.AsyncImage import ru.otus.marketsample.MarketSampleApp import ru.otus.marketsample.R -import ru.otus.marketsample.databinding.FragmentProductListBinding -import ru.otus.marketsample.products.feature.adapter.ProductsAdapter +import ru.otus.marketsample.details.feature.ErrorState +import ru.otus.marketsample.details.feature.LoadingState import ru.otus.marketsample.products.feature.di.DaggerProductListComponent import javax.inject.Inject class ProductListFragment : Fragment() { - private var _binding: FragmentProductListBinding? = null - private val binding get() = _binding!! - @Inject lateinit var factory: ProductListViewModelFactory @@ -42,75 +73,203 @@ class ProductListFragment : Fragment() { .inject(this) } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - _binding = FragmentProductListBinding.inflate(inflater, container, false) - return binding.root + ): View? { + return ComposeView(requireContext()).apply { + setContent { + ProductListScreen( + navigateToDetails = { productId -> + requireActivity().findNavController(R.id.nav_host_activity_main) + .navigate( + resId = R.id.action_main_to_details, + args = bundleOf("productId" to productId), + ) + }, + viewModel = viewModel + ) + } + } } +} + +@Composable +private fun ProductListScreen( + navigateToDetails: (productId: String) -> Unit, + viewModel: ProductListViewModel +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var isRefreshing by remember { mutableStateOf(false) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + ProductListContent( + state = state, + onErrorShown = viewModel::errorHasShown, + navigateToDetails = navigateToDetails, + isRefreshing = isRefreshing, + refresh = viewModel::refresh + ) +} - 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), - ) +@Composable +private fun ProductListContent( + state: ProductsScreenState, + onErrorShown: () -> Unit, + navigateToDetails: (productId: String) -> Unit, + isRefreshing: Boolean, + refresh: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color(0xFFFFFFFF)) + ) { + when { + state.isLoading -> LoadingState(Modifier.align(Alignment.Center)) + state.hasError -> { + ErrorState(modifier = Modifier.align(Alignment.BottomCenter), onErrorShown) } - ) - binding.recyclerView.layoutManager = LinearLayoutManager(context) - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh() + else -> ProductListState( + products = state.productListState, + onItemClick = { productId -> + navigateToDetails(productId) + }, + isRefreshing = isRefreshing, + onRefresh = 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) - } - } - } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProductListState( + products: List, + onItemClick: (String) -> Unit, + isRefreshing: Boolean, + onRefresh: () -> Unit +) { + val lazyListState = rememberLazyListState() + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp, 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(products) { product -> + ProductItem( + product = product, + onClick = { onItemClick(product.id) } + ) } } } +} - private fun showProductList(productListState: List) { - binding.progress.visibility = View.GONE - binding.recyclerView.visibility = View.VISIBLE - (binding.recyclerView.adapter as ProductsAdapter).submitList(productListState) - } - - private fun showLoading() { - binding.progress.visibility = View.VISIBLE - binding.recyclerView.visibility = View.GONE - } +@Composable +private fun ProductItem( + product: ProductState, + onClick: () -> Unit +) { + val priceBrush = Brush.linearGradient( + start = Offset(0f, Float.POSITIVE_INFINITY), + end = Offset(Float.POSITIVE_INFINITY, 0f), + colors = listOf(Color(0xFF6200EE), Color(0xFFBB86FC)) + ) - override fun onDestroyView() { - super.onDestroyView() - _binding = null + Row( + modifier = Modifier + .clickable { + onClick() + } + .height(130.dp)) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + AsyncImage( + model = product.image, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + if (product.hasDiscount) { + Text( + text = product.discount, + modifier = Modifier + .padding(8.dp) + .align(Alignment.TopEnd) + .border( + 2.dp, + Color(0xFFFFFFFF), + RoundedCornerShape(20.dp, 2.dp, 10.dp, 20.dp) + ) + .clip(RoundedCornerShape(20.dp, 2.dp, 10.dp, 20.dp)) + .background(priceBrush) + .padding(horizontal = 10.dp, vertical = 4.dp), + color = Color(0xFFFFFFFF), + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + } + Box( + modifier = Modifier + .padding(horizontal = 12.dp) + .fillMaxSize() + .weight(1f) + ) { + Text( + text = product.name, + modifier = Modifier.align(Alignment.TopStart), + color = Color(0xFF000000), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + overflow = TextOverflow.Ellipsis, + maxLines = 2 + ) + Text( + text = stringResource(R.string.price_with_arg, product.price), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(top = 12.dp) + .drawBehind { + drawRoundRect( + color = Color(0xFFFFF3E0), + cornerRadius = CornerRadius(8.dp.toPx(), 8.dp.toPx()) + ) + } + .padding(horizontal = 12.dp, vertical = 8.dp), + color = Color(0xFF6200EE), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } } } + +@Preview +@Composable +private fun PreviewProductItem() { + ProductItem( + ProductState( + id = "2", + name = "Product Name", + image = "", + price = "200", + hasDiscount = true, + discount = "-20%" + ) + ) { } +} \ 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 fb99eea..4f8bbcf 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 @@ -5,28 +5,45 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp 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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import ru.otus.common.di.findDependencies -import ru.otus.marketsample.databinding.FragmentPromoListBinding -import ru.otus.marketsample.promo.feature.adapter.PromoAdapter +import ru.otus.marketsample.details.feature.ErrorState +import ru.otus.marketsample.details.feature.LoadingState import ru.otus.marketsample.promo.feature.di.DaggerPromoComponent import javax.inject.Inject class PromoListFragment : Fragment() { - private var _binding: FragmentPromoListBinding? = null - private val binding get() = _binding!! - - @Inject - lateinit var adapter: PromoAdapter - @Inject lateinit var factory: PromoListViewModelFactory @@ -44,62 +61,134 @@ class PromoListFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - _binding = FragmentPromoListBinding.inflate(inflater, container, false) - return binding.root + ): View? { + return ComposeView(requireContext()).apply { + setContent { + PromoListScreen(viewModel) + } + } } +} - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) +@Composable +private fun PromoListScreen(viewModel: PromoListViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + var isRefreshing by remember { mutableStateOf(false) } + + PromoListContent( + state = state, + onErrorShown = viewModel::errorHasShown, + isRefreshing = isRefreshing, + refresh = viewModel::refresh + ) +} - binding.recyclerView.adapter = adapter - binding.recyclerView.layoutManager = LinearLayoutManager(context) +@Composable +private fun PromoListContent( + state: PromoScreenState, + onErrorShown: () -> Unit, + isRefreshing: Boolean, + refresh: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color(0xFFFFFFFF)) + ) { + when { + state.isLoading -> LoadingState(Modifier.align(Alignment.Center)) + state.hasError -> { + ErrorState(modifier = Modifier.align(Alignment.BottomCenter), onErrorShown) + } - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh() + else -> PromoListState( + promos = state.promoListState, + isRefreshing = isRefreshing, + onRefresh = 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) - } - } - } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PromoListState( + promos: List, + isRefreshing: Boolean, + onRefresh: () -> Unit +) { + val lazyListState = rememberLazyListState() + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize() + ) { + items(promos) { promo -> + PromoItem(promo) } } } +} - private fun showPromoList(promoListState: List) { - binding.progress.visibility = View.GONE - binding.recyclerView.visibility = View.VISIBLE - adapter.submitList(promoListState) - } - - private fun showLoading() { - binding.progress.visibility = View.VISIBLE - binding.recyclerView.visibility = View.GONE +@Composable +private fun PromoItem(promo: PromoState) { + val gradient = Brush.verticalGradient( + colors = listOf(Color(0x00000000), Color(0xaa000000)) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + AsyncImage( + model = promo.image, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(250.dp), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(100.dp) + .background(gradient) + ) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(10.dp) + ) { + Text( + text = promo.name, + color = Color(0xFFFFFFFF), + fontSize = 24.sp + ) + Text( + text = promo.description, + color = Color(0xFFFFFFFF), + fontSize = 14.sp + ) + } } +} - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } +@Preview +@Composable +private fun PreviewPromoItem() { + PromoItem( + PromoState( + id = "", + name = "Name", + description = "Bla bla bla bla", + image = "" + ) + ) } diff --git a/build.gradle b/build.gradle index cee1c80..f4e72af 100644 --- a/build.gradle +++ b/build.gradle @@ -5,4 +5,5 @@ plugins { alias(libs.plugins.kotlinxSerialization) apply false alias(libs.plugins.kapt) apply false alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.compose.compiler) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 51bd97e..a367c99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.2.2" +agp = "8.6.0" kotlin = "2.1.10" core-ktx = "1.15.0" junit = "4.13.2" @@ -25,6 +25,11 @@ serializationJson = "1.8.0" androidXDatastore = "1.1.4" kotlinx-coroutines-rx2 = "1.7.3" dagger = "2.55" +compose-bom = "2025.05.00" +compose-activity = "1.10.1" +compose-viewmodel = "2.8.5" +uiTooling = "1.8.3" +coil-compose = "2.4.0" [libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -51,9 +56,16 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "a androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidXDatastore" } kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlinSerialization" } kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serializationJson" } -kotlinx-coroutines-rx2 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx2", version.ref = "kotlinx-coroutines-rx2" } +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" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +material3 = { group = "androidx.compose.material3", name = "material3" } +compose-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" } +compose-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "compose-viewmodel" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } [bundles] network = ["okhttp", "okhttp-logging-interceptor", "retrofit", "retrofit-converter-gson"] @@ -64,4 +76,4 @@ 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" } - +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7534047..25f2303 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Aug 03 22:23:51 TRT 2023 +#Sat Jul 19 11:59:31 MSK 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/local.properties b/local.properties index a2e88de..f587a67 100644 --- a/local.properties +++ b/local.properties @@ -1,10 +1,8 @@ -## This file is automatically generated by Android Studio. -# Do not modify this file -- YOUR CHANGES WILL BE ERASED! -# -# This file should *NOT* be checked into Version Control Systems, +## This file must *NOT* be checked into Version Control Systems, # as it contains information specific to your local configuration. # # Location of the SDK. This is only used by Gradle. # For customization when using a Version Control System, please read the # header note. -sdk.dir=/Users/maximkachinkin/Library/Android/sdk \ No newline at end of file +#Thu Jul 17 16:45:34 MSK 2025 +sdk.dir=C\:\\Users\\Hp\\AppData\\Local\\Android\\Sdk From d3622f9845311875ffa9754f775c9d1dff1774b0 Mon Sep 17 00:00:00 2001 From: Hp Date: Sun, 20 Jul 2025 11:27:51 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20compose=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/build.gradle b/build.gradle index f4e72af..a8be002 100644 --- a/build.gradle +++ b/build.gradle @@ -6,4 +6,17 @@ plugins { alias(libs.plugins.kapt) apply false alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.compose.compiler) apply false +} + +allprojects { + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { + kotlinOptions { + freeCompilerArgs += [ + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${buildDir}/compose_metrics", + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${buildDir}/compose_metrics" + ] + } + } } \ No newline at end of file