diff --git a/app/build.gradle b/app/build.gradle index 1cdd96f..fd9f05b 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 { @@ -34,10 +35,33 @@ android { } buildFeatures { viewBinding true + compose true } } +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + metricsDestination = layout.buildDirectory.dir("compose_compiler") +} + dependencies { + + implementation platform(libs.androidx.compose.bom) + + implementation libs.androidx.compose.material3 + implementation libs.androidx.compose.foundation + implementation libs.androidx.compose.ui + implementation libs.androidx.compose.ui.viewbinding + + implementation libs.androidx.compose.ui.tooling.preview + implementation libs.androidx.fragment.compose + debugImplementation libs.androidx.compose.ui.tooling + + implementation libs.androidx.activity.compose + implementation libs.androidx.lifecycle.viewmodel.compose + + implementation libs.androidx.navigation.compose + implementation project(":common:di") implementation project(":common:formatters") implementation project(":common:ui") @@ -56,6 +80,8 @@ dependencies { implementation libs.navigation.fragment.ktx implementation libs.navigation.ui.ktx implementation libs.coil + implementation libs.coil.compose + implementation libs.coil.network implementation libs.gson implementation libs.bundles.network implementation libs.kotlin.serialization diff --git a/app/src/main/java/ru/otus/marketsample/MainActivity.kt b/app/src/main/java/ru/otus/marketsample/MainActivity.kt index 7e34aaf..18e2b76 100644 --- a/app/src/main/java/ru/otus/marketsample/MainActivity.kt +++ b/app/src/main/java/ru/otus/marketsample/MainActivity.kt @@ -1,26 +1,188 @@ package ru.otus.marketsample import android.os.Bundle +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import ru.otus.marketsample.databinding.ActivityMainBinding +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.os.bundleOf +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import androidx.navigation.findNavController +import androidx.navigation.navigation +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import ru.otus.marketsample.details.feature.navigation.FragmentHost +import ru.otus.marketsample.details.feature.navigation.detailNavHost +import ru.otus.marketsample.di.DaggerAppComponent +import ru.otus.marketsample.di.ViewModelsFactory +import ru.otus.marketsample.navigation.MainDestination +import ru.otus.marketsample.navigation.Product +import ru.otus.marketsample.navigation.Products +import ru.otus.marketsample.navigation.Promo +import ru.otus.marketsample.products.feature.navigation.productGraph +import ru.otus.marketsample.promo.navigation.promoGraph +import javax.inject.Inject +import ru.otus.common.ui.R as RUi + +private const val LENGTH_SHORT = 3000L class MainActivity : AppCompatActivity() { - private lateinit var binding: ActivityMainBinding + @Inject + lateinit var viewModelsFactory: ViewModelsFactory override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() + DaggerAppComponent.factory() + .create(this) + .inject(this) super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - ViewCompat.setOnApplyWindowInsetsListener(binding.container) { view, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets + + val mainScreens = listOf( + Products(), + Promo() + ) + + setContent { + var isShowSnackbar by remember { + mutableStateOf(false) + } + MaterialTheme { + var selected by remember { + mutableStateOf(Products()) + } + val navController = rememberNavController() + MaterialTheme { + Scaffold( + containerColor = colorResource(RUi.color.white), + snackbarHost = { + AnimatedVisibility( + isShowSnackbar, + enter = slideInVertically { -it }, + exit = slideOutVertically { it }, + ) { + Snackbar() { + Text( + modifier = Modifier.padding(16.dp), + text = "Error wile loading data" + ) + } + LaunchedEffect(isShowSnackbar) { + launch { + delay(LENGTH_SHORT) + isShowSnackbar = false + } + } + } + }, + bottomBar = { + BottomAppBar( + containerColor = colorResource(RUi.color.white), + modifier = Modifier + ) { + mainScreens.forEach { destination -> + NavigationBarItem( + colors = NavigationBarItemDefaults.colors( + indicatorColor = Color.Transparent, + selectedTextColor = colorResource(RUi.color.purple_500), + unselectedTextColor = colorResource(RUi.color.black), + selectedIconColor = colorResource(RUi.color.purple_500), + unselectedIconColor = colorResource(RUi.color.black), + ), + selected = selected == destination, + onClick = { + selected = destination + navController.navigate(destination) { + launchSingleTop = true + restoreState = true + popUpTo(Products()) { + saveState = true + inclusive = true + } + } + }, + label = { + Text( + text = getString(destination.labelRes), + fontSize = 12.sp, + fontWeight = if (selected == destination) { + FontWeight.Bold + } else { + FontWeight.Normal + } + ) + }, + icon = { + Icon( + modifier = Modifier + .size(24.dp), + painter = painterResource(destination.iconRes), + contentDescription = null, + ) + } + ) + } + } + } + ) { paddingsValue -> + NavHost( + modifier = Modifier + .padding(paddingsValue), + navController = navController, + startDestination = Product, + enterTransition = { + slideInHorizontally() + fadeIn() + }, + exitTransition = { + slideOutHorizontally() + fadeOut() + } + ) { + promoGraph(navController, viewModelsFactory) + navigation(startDestination = Products()) { + productGraph( + navController = navController, + viewModelFactory = viewModelsFactory, + openDetail = { + navController.navigate(FragmentHost(it)) + }, + showSnackbarCallback = { isShowSnackbar = it } + ) + detailNavHost(navController) + } + } + } + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/MainFragment.kt b/app/src/main/java/ru/otus/marketsample/MainFragment.kt deleted file mode 100644 index d03161e..0000000 --- a/app/src/main/java/ru/otus/marketsample/MainFragment.kt +++ /dev/null @@ -1,49 +0,0 @@ -package ru.otus.marketsample - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.fragment.app.Fragment -import androidx.navigation.Navigation.findNavController -import com.google.android.material.bottomnavigation.BottomNavigationView -import ru.otus.marketsample.databinding.FragmentMainBinding - -class MainFragment : Fragment() { - - private var _binding: FragmentMainBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentMainBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val navView: BottomNavigationView = binding.navView - - navView.setOnItemSelectedListener { - findNavController(binding.navHostFragmentMain).navigate(it.itemId) - true - } - - ViewCompat.setOnApplyWindowInsetsListener(binding.container) { view, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding(systemBars.left, 0, systemBars.right, 0) - insets - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ 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..bc5144d 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 @@ -6,12 +6,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.activity.addCallback 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.navigation.fragment.findNavController +import coil3.load import kotlinx.coroutines.launch import ru.otus.common.di.findDependencies import ru.otus.marketsample.details.feature.di.DaggerDetailsComponent diff --git a/app/src/main/java/ru/otus/marketsample/details/feature/navigation/DetailNavHost.kt b/app/src/main/java/ru/otus/marketsample/details/feature/navigation/DetailNavHost.kt new file mode 100644 index 0000000..43ded63 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/details/feature/navigation/DetailNavHost.kt @@ -0,0 +1,38 @@ +package ru.otus.marketsample.details.feature.navigation + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.core.os.bundleOf +import androidx.fragment.compose.AndroidFragment +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable +import ru.otus.marketsample.details.feature.DetailsFragment + +fun NavGraphBuilder.detailNavHost(navHostController: NavHostController) { + composable( + enterTransition = { scaleIn() + fadeIn() }, + exitTransition = { scaleOut() + fadeOut() } + ) {backstack -> + val id = backstack.arguments?.getString("productId") + Box( + modifier = Modifier + .fillMaxSize() + ) { + AndroidFragment( + modifier = Modifier + .fillMaxSize(), + arguments = bundleOf("productId" to id) + ) + } + } +} + +@Serializable +data class FragmentHost(val productId: String) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/di/AppComponent.kt b/app/src/main/java/ru/otus/marketsample/di/AppComponent.kt index 2b95681..a84dda4 100644 --- a/app/src/main/java/ru/otus/marketsample/di/AppComponent.kt +++ b/app/src/main/java/ru/otus/marketsample/di/AppComponent.kt @@ -7,6 +7,7 @@ import ru.otus.marketsample.details.feature.di.DetailsComponentDependencies import ru.otus.marketsample.products.feature.di.ProductListComponentDependencies import ru.otus.marketsample.promo.feature.di.PromoComponentDependencies import ru.otus.common.di.Dependencies +import ru.otus.marketsample.MainActivity import javax.inject.Singleton @Singleton @@ -14,16 +15,15 @@ import javax.inject.Singleton modules = [ NetworkModule::class, DataModule::class, + ViewModelModule::class, ] ) -interface AppComponent: - Dependencies, - DetailsComponentDependencies, - PromoComponentDependencies, - ProductListComponentDependencies -{ +interface AppComponent : Dependencies, + DetailsComponentDependencies { @Component.Factory interface Factory { fun create(@BindsInstance applicationContext: Context): AppComponent } + + fun inject(activity: MainActivity) } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/di/ViewModelKey.kt b/app/src/main/java/ru/otus/marketsample/di/ViewModelKey.kt new file mode 100644 index 0000000..5e5f1ff --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/di/ViewModelKey.kt @@ -0,0 +1,10 @@ +package ru.otus.marketsample.di + +import androidx.lifecycle.ViewModel +import dagger.MapKey +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class ViewModelKey(val value: KClass) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/di/ViewModelModule.kt b/app/src/main/java/ru/otus/marketsample/di/ViewModelModule.kt new file mode 100644 index 0000000..11273db --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/di/ViewModelModule.kt @@ -0,0 +1,26 @@ +package ru.otus.marketsample.di + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Binds +import dagger.Module +import dagger.multibindings.IntoMap +import ru.otus.marketsample.products.feature.ProductListViewModel +import ru.otus.marketsample.promo.feature.PromoListViewModel + +@Module +interface ViewModelModule { + + @Binds + fun bindViewModelFactory(impl: ViewModelsFactory): ViewModelProvider.Factory + + @Binds + @IntoMap + @ViewModelKey(ProductListViewModel::class) + fun bindProductListViewModel(impl: ProductListViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(PromoListViewModel::class) + fun bindPromoListViewModel(impl: PromoListViewModel): ViewModel +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/di/ViewModelsFactory.kt b/app/src/main/java/ru/otus/marketsample/di/ViewModelsFactory.kt new file mode 100644 index 0000000..395de5c --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/di/ViewModelsFactory.kt @@ -0,0 +1,15 @@ +package ru.otus.marketsample.di + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import javax.inject.Inject +import javax.inject.Provider + +@Suppress("UNCHECKED_CAST") +class ViewModelsFactory @Inject constructor(val vmMap: Map, @JvmSuppressWildcards Provider>) : + ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + return vmMap.getValue(modelClass).get() as T + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/navigation/MainNavHost.kt b/app/src/main/java/ru/otus/marketsample/navigation/MainNavHost.kt new file mode 100644 index 0000000..699be87 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/navigation/MainNavHost.kt @@ -0,0 +1,27 @@ +package ru.otus.marketsample.navigation + +import kotlinx.serialization.Serializable +import ru.otus.marketsample.R +import ru.otus.common.ui.R as RUi + +interface MainDestination { + val labelRes: Int + val iconRes: Int +} + +@Serializable +data class +Products( + override val labelRes: Int = R.string.title_products, + override val iconRes: Int = RUi.drawable.ic_list, +) : MainDestination + +@Serializable +data class Promo( + override val labelRes: Int = R.string.title_promo, + override val iconRes: Int = RUi.drawable.ic_discount, +) : MainDestination + + +@Serializable +object Product 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 deleted file mode 100644 index 88f7ec0..0000000 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt +++ /dev/null @@ -1,117 +0,0 @@ -package ru.otus.marketsample.products.feature - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -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.databinding.FragmentProductListBinding -import ru.otus.marketsample.products.feature.adapter.ProductsAdapter -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 - - private val viewModel: ProductListViewModel by viewModels { factory } - - override fun onAttach(context: Context) { - super.onAttach(context) - - val appComponent = (activity?.applicationContext as MarketSampleApp).appComponent - - DaggerProductListComponent.factory() - .create(appComponent) - .inject(this) - } - - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentProductListBinding.inflate(inflater, container, false) - return binding.root - } - - 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) - } - } - } - } - } - } - - 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/ProductListViewModel.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModel.kt index ce33e63..736c352 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModel.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModel.kt @@ -6,16 +6,17 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update -import ru.otus.marketsample.products.domain.ConsumeProductsUseCase import ru.otus.marketsample.R +import ru.otus.marketsample.products.domain.ConsumeProductsUseCase +import javax.inject.Inject + -class ProductListViewModel( +class ProductListViewModel @Inject constructor( private val consumeProductsUseCase: ConsumeProductsUseCase, private val productStateFactory: ProductStateFactory, ) : ViewModel() { diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModelFactory.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModelFactory.kt deleted file mode 100644 index 55eaeb7..0000000 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModelFactory.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ru.otus.marketsample.products.feature - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.CreationExtras -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.products.domain.ConsumeProductsUseCase -import javax.inject.Inject - -@FeatureScope -class ProductListViewModelFactory @Inject constructor( - private val consumeProductsUseCase: ConsumeProductsUseCase, - private val productStateFactory: ProductStateFactory, -) : - ViewModelProvider.Factory { - - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - when { - modelClass.isAssignableFrom(ProductListViewModel::class.java) -> { - @Suppress("UNCHECKED_CAST") - return ProductListViewModel( - consumeProductsUseCase = consumeProductsUseCase, - productStateFactory = productStateFactory, - ) as T - } - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductStateFactory.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductStateFactory.kt index 51f0490..4c10dff 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductStateFactory.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductStateFactory.kt @@ -1,12 +1,10 @@ package ru.otus.marketsample.products.feature -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.products.domain.Product import ru.otus.common.formatters.DiscountFormatter import ru.otus.common.formatters.PriceFormatter +import ru.otus.marketsample.products.domain.Product import javax.inject.Inject -@FeatureScope class ProductStateFactory @Inject constructor( private val discountFormatter: DiscountFormatter, private val priceFormatter: PriceFormatter, diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductsScreen.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductsScreen.kt new file mode 100644 index 0000000..50a9edf --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductsScreen.kt @@ -0,0 +1,127 @@ +package ru.otus.marketsample.products.feature + +//import coil.request.ImageRequest +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage +import ru.otus.common.ui.R +import ru.otus.components.Badge +import ru.otus.components.Price +import ru.otus.components.ProductCard +import ru.otus.marketsample.di.ViewModelsFactory + +@Composable +fun ProductsScreen( + viewModelFactory: ViewModelsFactory, + viewModel: ProductListViewModel = viewModel(factory = viewModelFactory), + showSnackbarCallback: (Boolean) -> Unit, + openDetail: (id: String) -> Unit, +) { + val state by viewModel.state.collectAsState() + + Surface( + color = colorResource(R.color.white), + ) { + when { + state.isLoading -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + state.hasError -> { + showSnackbarCallback.invoke(state.hasError) + + viewModel.errorHasShown() + } + + else -> Content(state, openDetail) + } + } +} + +@Composable +private fun Content(state: ProductsScreenState, openDetail: (id: String) -> Unit) { + LazyColumn( + modifier = Modifier + .padding(horizontal = 16.dp), + ) { + items( + count = state.productListState.size, + key = { index -> state.productListState[index].id }, + contentType = { index -> state.productListState[index] } + ) { index -> + val item = state.productListState[index] + ProductCard( + modifier = Modifier + .padding(vertical = 24.dp) + .clickable { + openDetail.invoke(item.id) + }, + image = { + AsyncImage( + model = item.image, + contentDescription = null, + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Crop, + ) + }, + badge = { + if (item.hasDiscount) { + Badge( + value = item.discount, + modifier = Modifier + .align(Alignment.TopEnd), + ) + } + }, + title = { + Text( + text = item.name, + modifier = Modifier + .fillMaxWidth(), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + fontSize = 18.sp, + color = colorResource(R.color.black), + fontWeight = FontWeight.Medium + ) + }, + price = { + Price( + value = item.price, + modifier = Modifier + .align(Alignment.End) + ) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/adapter/ProductHolder.kt b/app/src/main/java/ru/otus/marketsample/products/feature/adapter/ProductHolder.kt deleted file mode 100644 index f216b25..0000000 --- a/app/src/main/java/ru/otus/marketsample/products/feature/adapter/ProductHolder.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ru.otus.marketsample.products.feature.adapter - -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.recyclerview.widget.RecyclerView -import coil.load -import ru.otus.marketsample.R -import ru.otus.marketsample.databinding.ItemProductBinding -import ru.otus.marketsample.products.feature.ProductState - -class ProductHolder( - private val binding: ItemProductBinding, - private val onItemClicked: (String) -> Unit, -) : RecyclerView.ViewHolder(binding.root) { - - fun bind(productState: ProductState) { - binding.image.load(productState.image) - binding.name.text = productState.name - binding.price.text = - binding.root.resources.getString(R.string.price_with_arg, productState.price) - if (productState.hasDiscount) { - binding.promo.visibility = VISIBLE - binding.promo.text = productState.discount - } else { - binding.promo.visibility = GONE - } - - binding.root.setOnClickListener { - onItemClicked(productState.id) - } - } -} diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/adapter/ProductsAdapter.kt b/app/src/main/java/ru/otus/marketsample/products/feature/adapter/ProductsAdapter.kt deleted file mode 100644 index 18354a2..0000000 --- a/app/src/main/java/ru/otus/marketsample/products/feature/adapter/ProductsAdapter.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ru.otus.marketsample.products.feature.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.databinding.ItemProductBinding -import ru.otus.marketsample.products.feature.ProductState -import javax.inject.Inject - -@FeatureScope -class ProductsAdapter @Inject constructor( - private val onItemClicked: (String) -> Unit, -) : - ListAdapter(DiffCallback()) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductHolder { - return ProductHolder( - binding = ItemProductBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ), - onItemClicked = onItemClicked, - ) - } - - override fun onBindViewHolder(holder: ProductHolder, position: Int) { - val entity = getItem(position) - entity?.let { - holder.bind(entity) - } - } -} - -private class DiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: ProductState, newItem: ProductState): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: ProductState, newItem: ProductState): Boolean { - return oldItem == newItem - } -} diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/di/ProductListComponent.kt b/app/src/main/java/ru/otus/marketsample/products/feature/di/ProductListComponent.kt index 2b4c9fd..5ca8784 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/di/ProductListComponent.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/di/ProductListComponent.kt @@ -4,7 +4,6 @@ import dagger.Component import ru.otus.common.data.products.ProductRepository import ru.otus.common.data.promo.PromoRepository import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.products.feature.ProductListFragment @FeatureScope @Component(dependencies = [ProductListComponentDependencies::class]) @@ -16,8 +15,6 @@ interface ProductListComponent { dependencies: ProductListComponentDependencies, ): ProductListComponent } - - fun inject(productListFragment: ProductListFragment) } interface ProductListComponentDependencies { diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/navigation/ProductsNavHost.kt b/app/src/main/java/ru/otus/marketsample/products/feature/navigation/ProductsNavHost.kt new file mode 100644 index 0000000..deb9c10 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/feature/navigation/ProductsNavHost.kt @@ -0,0 +1,28 @@ +package ru.otus.marketsample.products.feature.navigation + +import androidx.core.os.bundleOf +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable +import ru.otus.marketsample.R +import ru.otus.marketsample.di.ViewModelsFactory +import ru.otus.marketsample.navigation.Products +import ru.otus.marketsample.products.feature.ProductsScreen + +fun NavGraphBuilder.productGraph( + navController: NavController, + viewModelFactory: ViewModelsFactory, + showSnackbarCallback: (Boolean) -> Unit, + openDetail: (id: String) -> Unit, +) { + composable { + + ProductsScreen( + viewModelFactory = viewModelFactory, + showSnackbarCallback = showSnackbarCallback, + openDetail = openDetail + ) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/promo/PromoScreen.kt b/app/src/main/java/ru/otus/marketsample/promo/PromoScreen.kt new file mode 100644 index 0000000..cca900a --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/promo/PromoScreen.kt @@ -0,0 +1,77 @@ +package ru.otus.marketsample.promo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage +import ru.otus.components.PromoCard +import ru.otus.marketsample.di.ViewModelsFactory +import ru.otus.marketsample.promo.feature.PromoListViewModel +import ru.otus.common.ui.R as RUi + +@Composable +fun PromoScreen( + viewModelsFactory: ViewModelsFactory, + viewModel: PromoListViewModel = viewModel(factory = viewModelsFactory) +) { + val state by viewModel.state.collectAsState() + + Surface( + modifier = Modifier + .fillMaxSize(), + color = colorResource(RUi.color.white) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + items( + count = state.promoListState.size, + key = { state.promoListState[it].id }, + contentType = { state.promoListState[it] }, + ) { index -> + val promo = state.promoListState[index] + PromoCard( + image = { + AsyncImage( + model = promo.image, + contentDescription = null, + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Crop, + ) + }, + title = { + Text( + text = promo.name, + fontSize = 24.sp, + color = colorResource(RUi.color.white), + ) + }, + description = { + Text( + text = promo.description, + style = MaterialTheme.typography.bodyMedium, + color = colorResource(RUi.color.white) + ) + } + ) + } + } + } +} \ 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 deleted file mode 100644 index 2e4f533..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt +++ /dev/null @@ -1,106 +0,0 @@ -package ru.otus.marketsample.promo.feature - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -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.databinding.FragmentPromoListBinding -import ru.otus.marketsample.promo.feature.adapter.PromoAdapter -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 - - private val viewModel: PromoListViewModel by viewModels { factory } - - override fun onAttach(context: Context) { - super.onAttach(context) - - DaggerPromoComponent.factory() - .create(dependencies = findDependencies()) - .inject(this) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentPromoListBinding.inflate(inflater, container, false) - return binding.root - } - - 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) - } - } - } - } - } - } - - 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/java/ru/otus/marketsample/promo/feature/PromoListViewModel.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModel.kt index 6343012..2b0321d 100644 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModel.kt +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModel.kt @@ -11,10 +11,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update -import ru.otus.marketsample.promo.domain.ConsumePromosUseCase import ru.otus.marketsample.R +import ru.otus.marketsample.promo.domain.ConsumePromosUseCase +import javax.inject.Inject -class PromoListViewModel( +class PromoListViewModel @Inject constructor( private val promoStateFactory: PromoStateFactory, private val consumePromosUseCase: ConsumePromosUseCase, ) : ViewModel() { diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModelFactory.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModelFactory.kt deleted file mode 100644 index f397c06..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModelFactory.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ru.otus.marketsample.promo.feature - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.CreationExtras -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.promo.domain.ConsumePromosUseCase -import javax.inject.Inject - -@FeatureScope -class PromoListViewModelFactory @Inject constructor( - private val promoStateFactory: PromoStateFactory, - private val consumePromosUseCase: ConsumePromosUseCase, -) : - ViewModelProvider.Factory { - - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - when { - modelClass.isAssignableFrom(PromoListViewModel::class.java) -> { - @Suppress("UNCHECKED_CAST") - return PromoListViewModel( - promoStateFactory = promoStateFactory, - consumePromosUseCase = consumePromosUseCase, - ) as T - } - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoStateFactory.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoStateFactory.kt index 6d350a6..8be7ea7 100644 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoStateFactory.kt +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoStateFactory.kt @@ -4,7 +4,6 @@ import ru.otus.common.di.FeatureScope import ru.otus.marketsample.promo.domain.Promo import javax.inject.Inject -@FeatureScope class PromoStateFactory @Inject constructor() { fun map(promo: Promo): PromoState { return PromoState( diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoAdapter.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoAdapter.kt deleted file mode 100644 index 0f6b562..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoAdapter.kt +++ /dev/null @@ -1,40 +0,0 @@ -package ru.otus.marketsample.promo.feature.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.databinding.ItemPromoBinding -import ru.otus.marketsample.promo.feature.PromoState -import javax.inject.Inject - -@FeatureScope -class PromoAdapter @Inject constructor() : ListAdapter(DiffCallback()) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PromoHolder { - return PromoHolder( - ItemPromoBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - ) - } - - override fun onBindViewHolder(holder: PromoHolder, position: Int) { - val entity = getItem(position) - entity?.let { - holder.bind(entity) - } - } -} - -private class DiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: PromoState, newItem: PromoState): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: PromoState, newItem: PromoState): Boolean { - return oldItem == newItem - } -} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoHolder.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoHolder.kt deleted file mode 100644 index 5d08f5d..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoHolder.kt +++ /dev/null @@ -1,17 +0,0 @@ -package ru.otus.marketsample.promo.feature.adapter - -import androidx.recyclerview.widget.RecyclerView -import coil.load -import ru.otus.marketsample.databinding.ItemPromoBinding -import ru.otus.marketsample.promo.feature.PromoState - -class PromoHolder( - private val binding: ItemPromoBinding, -) : RecyclerView.ViewHolder(binding.root) { - - fun bind(promoState: PromoState) { - binding.image.load(promoState.image) - binding.name.text = promoState.name - binding.description.text = promoState.description - } -} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt index b1ad582..aebbe50 100644 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt @@ -3,7 +3,6 @@ package ru.otus.marketsample.promo.feature.di import dagger.Component import ru.otus.common.data.promo.PromoRepository import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.promo.feature.PromoListFragment @FeatureScope @Component(dependencies = [PromoComponentDependencies::class]) @@ -13,8 +12,6 @@ interface PromoComponent { interface Factory { fun create(dependencies: PromoComponentDependencies): PromoComponent } - - fun inject(productFragment: PromoListFragment) } interface PromoComponentDependencies { diff --git a/app/src/main/java/ru/otus/marketsample/promo/navigation/PromoNavHost.kt b/app/src/main/java/ru/otus/marketsample/promo/navigation/PromoNavHost.kt new file mode 100644 index 0000000..9a85f57 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/promo/navigation/PromoNavHost.kt @@ -0,0 +1,19 @@ +package ru.otus.marketsample.promo.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import ru.otus.marketsample.di.ViewModelsFactory +import ru.otus.marketsample.navigation.Promo +import ru.otus.marketsample.promo.PromoScreen + +fun NavGraphBuilder.promoGraph( + navController: NavController, + viewModelFactory: ViewModelsFactory +) { + composable { + PromoScreen( + viewModelsFactory = viewModelFactory + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 9945e95..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml deleted file mode 100644 index 8252830..0000000 --- a/app/src/main/res/layout/fragment_main.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_product_list.xml b/app/src/main/res/layout/fragment_product_list.xml deleted file mode 100644 index 7dc8ee3..0000000 --- a/app/src/main/res/layout/fragment_product_list.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_promo_list.xml b/app/src/main/res/layout/fragment_promo_list.xml deleted file mode 100644 index da5a492..0000000 --- a/app/src/main/res/layout/fragment_promo_list.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_product.xml b/app/src/main/res/layout/item_product.xml deleted file mode 100644 index 29944da..0000000 --- a/app/src/main/res/layout/item_product.xml +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_promo.xml b/app/src/main/res/layout/item_promo.xml deleted file mode 100644 index f12f53e..0000000 --- a/app/src/main/res/layout/item_promo.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - 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/common/ui/build.gradle b/common/ui/build.gradle index c7261af..5c470b4 100644 --- a/common/ui/build.gradle +++ b/common/ui/build.gradle @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) } android { @@ -18,10 +19,23 @@ android { kotlinOptions { jvmTarget = '17' } + + buildFeatures { + compose true + } } dependencies { implementation libs.core.ktx implementation libs.appcompat implementation libs.material + + implementation platform(libs.androidx.compose.bom) + + implementation libs.androidx.compose.material3 + implementation libs.androidx.compose.foundation + implementation libs.androidx.compose.ui + + implementation libs.androidx.compose.ui.tooling.preview + debugImplementation libs.androidx.compose.ui.tooling } \ No newline at end of file diff --git a/common/ui/src/main/kotlin/ru/otus/components/Badge.kt b/common/ui/src/main/kotlin/ru/otus/components/Badge.kt new file mode 100644 index 0000000..f9a5d9d --- /dev/null +++ b/common/ui/src/main/kotlin/ru/otus/components/Badge.kt @@ -0,0 +1,72 @@ +package ru.otus.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.colorResource +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 ru.otus.common.ui.R + +@Composable +fun Badge( + value: String, + modifier: Modifier = Modifier, +) { + val color1 = colorResource(R.color.purple_500) + val color2 = colorResource(R.color.purple_700) + Text( + text = value, + modifier = modifier + .padding(8.dp) + .graphicsLayer { + shape = RoundedCornerShape( + topStart = 40.dp, + topEnd = 4.dp, + bottomStart = 40.dp, + bottomEnd = 20.dp, + ) + clip = true + } + .border( + 2.dp, + color = colorResource(R.color.white), + shape = RoundedCornerShape( + topStart = 40.dp, + topEnd = 4.dp, + bottomStart = 40.dp, + bottomEnd = 20.dp, + ) + ) + .drawBehind { + drawRect( + brush = Brush.linearGradient( + colors = listOf(color1, color2) + ) + ) + } + .padding(horizontal = 10.dp, vertical = 4.dp), + color = colorResource(R.color.white), + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) +} + +@Preview +@Composable +private fun Badge_Preview() { + MaterialTheme { + Badge( + value = "20%" + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/kotlin/ru/otus/components/MarketSnackbar.kt b/common/ui/src/main/kotlin/ru/otus/components/MarketSnackbar.kt new file mode 100644 index 0000000..2800089 --- /dev/null +++ b/common/ui/src/main/kotlin/ru/otus/components/MarketSnackbar.kt @@ -0,0 +1,7 @@ +package ru.otus.components + +import androidx.compose.runtime.Composable + +@Composable +fun MarketSnackbar() { +} \ No newline at end of file diff --git a/common/ui/src/main/kotlin/ru/otus/components/Price.kt b/common/ui/src/main/kotlin/ru/otus/components/Price.kt new file mode 100644 index 0000000..d713647 --- /dev/null +++ b/common/ui/src/main/kotlin/ru/otus/components/Price.kt @@ -0,0 +1,47 @@ +package ru.otus.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.colorResource +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 ru.otus.common.ui.R + +@Composable +fun Price( + value: String, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier + .graphicsLayer { + shape = RoundedCornerShape(8.dp) + clip = true + } + .background(Color(0xFFFFF3E0.toInt())) + .padding(horizontal = 12.dp, vertical = 8.dp), + text = value, + color = colorResource(R.color.purple_500), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) +} + +@Preview(showBackground = true) +@Composable +private fun Price_Preview() { + MaterialTheme { + Price( + value = "2000 руб" + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/kotlin/ru/otus/components/ProductCard.kt b/common/ui/src/main/kotlin/ru/otus/components/ProductCard.kt new file mode 100644 index 0000000..94167ab --- /dev/null +++ b/common/ui/src/main/kotlin/ru/otus/components/ProductCard.kt @@ -0,0 +1,114 @@ +package ru.otus.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +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.shape.RoundedCornerShape +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.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +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 ru.otus.common.ui.R + +@Composable +fun ProductCard( + modifier: Modifier = Modifier, + image: @Composable BoxScope.() -> Unit = {}, + badge: @Composable BoxScope.() -> Unit = {}, + title: @Composable ColumnScope.() -> Unit = {}, + price: @Composable ColumnScope.() -> Unit = {}, +) { + Column( + modifier = modifier + .height(130.dp) + .background(color = colorResource(R.color.white)), + ) { + Row { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .graphicsLayer { + shape = RoundedCornerShape(12.dp) + clip = true + } + ) { + image.invoke(this) + badge.invoke(this) + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp) + .weight(1f), + verticalArrangement = Arrangement.SpaceBetween + ) { + title.invoke(this) + price.invoke(this) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ProductCard_Preview() { + MaterialTheme { + ProductCard( + image = { + Image( + painter = painterResource(R.drawable.cats), + contentDescription = null, + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Crop + ) + }, + badge = { + Badge( + value = "20%", + modifier = Modifier + .align(Alignment.TopEnd) + ) + }, + title = { + Text( + text = "Персиковый кранч", + modifier = Modifier + .fillMaxWidth(), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + fontSize = 18.sp, + color = colorResource(R.color.black), + fontWeight = FontWeight.Medium + ) + }, + price = { + Price( + "2000 руб", + modifier = Modifier + .align(Alignment.End) + ) + } + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/kotlin/ru/otus/components/PromoCard.kt b/common/ui/src/main/kotlin/ru/otus/components/PromoCard.kt new file mode 100644 index 0000000..9093b93 --- /dev/null +++ b/common/ui/src/main/kotlin/ru/otus/components/PromoCard.kt @@ -0,0 +1,82 @@ +package ru.otus.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.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.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.otus.common.ui.R + +@Composable +fun PromoCard( + modifier: Modifier = Modifier, + image: @Composable () -> Unit, + title: @Composable () -> Unit, + description: @Composable () -> Unit, +) { + val color2 = Color(0x00000000) + val color1 = Color(0x00AA0000) + Box( + modifier = modifier + .fillMaxWidth() + .height(250.dp) + ) { + image.invoke() + Column( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawRect( + brush = Brush.linearGradient( + colors = listOf(color1, color2) + ) + ) + } + .padding(10.dp) + .align(Alignment.BottomCenter), + verticalArrangement = Arrangement.Bottom + ) { + title.invoke() + description.invoke() + } + } +} + +@Preview +@Composable +fun PromoCard_Preview() { + MaterialTheme { + PromoCard( + image = { + Image( + painter = painterResource(R.drawable.cats), + contentDescription = null, + contentScale = ContentScale.Crop + ) + }, + title = { + Text("Какой-то текст заголовка") + }, + description = { + Text("Какой-то текст описания") + } + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/kotlin/ru/otus/theme/MarketTheme.kt b/common/ui/src/main/kotlin/ru/otus/theme/MarketTheme.kt new file mode 100644 index 0000000..798ddf3 --- /dev/null +++ b/common/ui/src/main/kotlin/ru/otus/theme/MarketTheme.kt @@ -0,0 +1,26 @@ +package ru.otus.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + + +@Composable +fun MarketTheme( + isDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + + val colorScheme = if (isDarkTheme) { + darkColorScheme() + } else { + lightColorScheme() + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} \ No newline at end of file diff --git a/common/ui/src/main/res/drawable/cats.jpg b/common/ui/src/main/res/drawable/cats.jpg new file mode 100644 index 0000000..2d2c3ba Binary files /dev/null and b/common/ui/src/main/res/drawable/cats.jpg differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad9bf92..8b327de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,14 @@ [versions] +activityCompose = "1.12.4" agp = "8.11.1" -kotlin = "2.2.10" +composeBom = "2026.02.01" +kotlin = "2.3.10" core-ktx = "1.17.0" junit = "4.13.2" androidx-test-ext-junit = "1.2.1" espresso-core = "3.6.1" appcompat = "1.7.1" +lifecycleViewmodelCompose = "2.10.0" material = "1.12.0" constraintlayout = "2.2.1" lifecycle-livedata-ktx = "2.9.3" @@ -14,8 +17,10 @@ fragment-ktx = "1.8.9" lifecycle-runtime-ktx = "2.9.3" navigation-fragment-ktx = "2.9.3" navigation-ui-ktx = "2.9.3" -coil = "2.7.0" +coil = "3.0.3" +coilView = "2.7.0" gson = "2.13.1" +navigationCompose = "2.9.7" okhttp = "4.12.0" retrofit = "2.11.0" swiperefreshlayout = "1.1.0" @@ -24,9 +29,22 @@ kotlinSerialization = "2.1.10" serializationJson = "1.9.0" androidXDatastore = "1.1.7" kotlinx-coroutines-rx2 = "1.7.3" -dagger = "2.57.1" +dagger = "2.59.2" +fragmentCompose = "1.8.9" [libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } @@ -40,7 +58,7 @@ fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation-fragment-ktx" } navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation-ui-ktx" } -coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } +coil = { group = "io.coil-kt", name = "coil", version.ref = "coilView" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } @@ -54,6 +72,7 @@ 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-fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "fragmentCompose" } [bundles] network = ["okhttp", "okhttp-logging-interceptor", "retrofit", "retrofit-converter-gson"] @@ -64,4 +83,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" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }