diff --git a/app/build.gradle b/app/build.gradle index 1cdd96f..1eb82cb 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.composeCompiler) } android { @@ -33,7 +34,12 @@ android { jvmTarget = '17' } buildFeatures { - viewBinding true + compose true + } + + composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_reports") + metricsDestination = layout.buildDirectory.dir("compose_metrics") } } @@ -44,18 +50,7 @@ dependencies { implementation project(":common:data:products") implementation project(":common:data:promo") - implementation libs.core.ktx - implementation libs.appcompat - implementation libs.material - implementation libs.constraintlayout - implementation libs.swipetorefresh - implementation libs.lifecycle.livedata.ktx - implementation libs.lifecycle.viewmodel.ktx - implementation libs.fragment.ktx - implementation libs.lifecycle.runtime.ktx - implementation libs.navigation.fragment.ktx - implementation libs.navigation.ui.ktx - implementation libs.coil + implementation libs.coil.compose implementation libs.gson implementation libs.bundles.network implementation libs.kotlin.serialization @@ -69,4 +64,14 @@ dependencies { testImplementation libs.junit androidTestImplementation libs.androidx.test.ext.junit androidTestImplementation libs.espresso.core + + implementation libs.androidx.activity.compose + implementation libs.androidx.navigation.compose + + implementation platform(libs.androidx.compose.bom) + implementation libs.androidx.material3 + + // Android Studio Preview support + implementation libs.androidx.ui.tooling.preview + debugImplementation libs.androidx.ui.tooling } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/MainActivity.kt b/app/src/main/java/ru/otus/marketsample/MainActivity.kt index 7e34aaf..0f2aad3 100644 --- a/app/src/main/java/ru/otus/marketsample/MainActivity.kt +++ b/app/src/main/java/ru/otus/marketsample/MainActivity.kt @@ -1,26 +1,84 @@ package ru.otus.marketsample import android.os.Bundle +import androidx.activity.ComponentActivity +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 - -class MainActivity : AppCompatActivity() { - - private lateinit var binding: ActivityMainBinding +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.otus.marketsample.common.ObserveAsEvents +import ru.otus.marketsample.navigation.BottomBar +import ru.otus.marketsample.navigation.MarketSampleNavHost +import ru.otus.marketsample.navigation.SnackbarController +import ru.otus.marketsample.navigation.rememberBarVisibility +import ru.otus.marketsample.navigation.rememberNavigationState +import ru.otus.marketsample.navigation.rememberSnackbarHostState +import theme.MarketSampleTheme +class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() 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 + enableEdgeToEdge() + setContent { + MarketSampleTheme { + val bottomBarState = rememberBarVisibility() + val navigationState = rememberNavigationState() + val snackbarHostState = rememberSnackbarHostState() + val scope = rememberCoroutineScope() + + ObserveAsEvents( + flow = SnackbarController.events, + snackbarHostState, + ) { event -> + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar( + message = event.message, + duration = SnackbarDuration.Short, + ) + } + } + + Scaffold( + bottomBar = { + Box( + modifier = Modifier.height(100.dp) + ) { + Crossfade( + targetState = bottomBarState.isVisible, + animationSpec = tween(200), + ) { isBottomBarVisible -> + when (isBottomBarVisible) { + true -> BottomBar(navigationState) + false -> Spacer(modifier = Modifier) + } + } + } + }, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + ) + }, + ) { + MarketSampleNavHost( + navigationState, + bottomBarState, + Modifier.padding(it), + ) + } + } } } } \ 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/MarketSampleApp.kt b/app/src/main/java/ru/otus/marketsample/MarketSampleApp.kt index 0ebd60f..b6e0c32 100644 --- a/app/src/main/java/ru/otus/marketsample/MarketSampleApp.kt +++ b/app/src/main/java/ru/otus/marketsample/MarketSampleApp.kt @@ -1,10 +1,12 @@ package ru.otus.marketsample import android.app.Application -import ru.otus.marketsample.di.AppComponent -import ru.otus.marketsample.di.DaggerAppComponent +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import ru.otus.common.di.Dependencies import ru.otus.common.di.DependenciesProvider +import ru.otus.marketsample.di.AppComponent +import ru.otus.marketsample.di.DaggerAppComponent class MarketSampleApp: Application(), DependenciesProvider { val appComponent: AppComponent = DaggerAppComponent.factory().create(this) @@ -13,3 +15,8 @@ class MarketSampleApp: Application(), DependenciesProvider { return appComponent } } + +@Composable +fun getApplicationComponent(): AppComponent { + return (LocalContext.current.applicationContext as MarketSampleApp).appComponent +} diff --git a/app/src/main/java/ru/otus/marketsample/common/Discount.kt b/app/src/main/java/ru/otus/marketsample/common/Discount.kt new file mode 100644 index 0000000..eb30098 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/common/Discount.kt @@ -0,0 +1,73 @@ +package ru.otus.marketsample.common + +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.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import theme.MarketSampleTheme +import theme.purple_200 +import theme.purple_500 +import theme.white + +@Composable +fun Discount( + discount: String, + style: TextStyle, + modifier: Modifier = Modifier, +) { + val roundedCornerShape = RoundedCornerShape( + topStart = 40.dp, + topEnd = 10.dp, + bottomEnd = 40.dp, + bottomStart = 40.dp, + ) + + Text( + text = discount, + style = style, + modifier = modifier + .clip(roundedCornerShape) + .drawBehind { + drawRect( + brush = Brush.linearGradient( + colors = listOf( + purple_200, + purple_500, + ), + start = Offset(x = 0f, y = size.height), + end = Offset(x = size.width, y = 0f), + ), + ) + } + .border( + width = 2.dp, + color = white, + shape = roundedCornerShape, + ) + .padding( + vertical = 4.dp, + horizontal = 10.dp, + ), + ) +} + +@Composable +@PreviewLightDark +private fun DiscountPreview() { + MarketSampleTheme { + Discount( + discount = "-20%", + style = MaterialTheme.typography.displayLarge, + ) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/common/ObserveAsEvents.kt b/app/src/main/java/ru/otus/marketsample/common/ObserveAsEvents.kt new file mode 100644 index 0000000..ebeb45c --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/common/ObserveAsEvents.kt @@ -0,0 +1,26 @@ +package ru.otus.marketsample.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + flow: Flow, + key: Any? = null, + onEvent: (T) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner.lifecycle, key, flow) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} 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 deleted file mode 100644 index e23c57e..0000000 --- a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsFragment.kt +++ /dev/null @@ -1,125 +0,0 @@ -package ru.otus.marketsample.details.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 coil.load -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 javax.inject.Inject - -class DetailsFragment : Fragment() { - - private var _binding: FragmentDetailsBinding? = null - private val binding get() = _binding!! - - @Inject - lateinit var factory: DetailsViewModelFactory - - private val viewModel: DetailsViewModel by viewModels( - factoryProducer = { factory } - ) - - private val productId by lazy { arguments?.getString("productId")!! } - - override fun onAttach(context: Context) { - super.onAttach(context) - - DaggerDetailsComponent.factory() - .create( - dependencies = findDependencies(), - productId = productId, - ) - .inject(this) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentDetailsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - 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 -> showProduct(detailsState = state.detailsState) - } - } - } - } - } - } - - private fun showLoading() { - hideAll() - binding.progress.visibility = View.VISIBLE - } - - private fun showProduct(detailsState: DetailsState) { - hideAll() - binding.image.load(detailsState.image) - binding.image.visibility = View.VISIBLE - - binding.name.text = detailsState.name - binding.name.visibility = View.VISIBLE - - binding.price.text = getString(R.string.price_with_arg, detailsState.price) - binding.price.visibility = View.VISIBLE - - if (detailsState.hasDiscount) { - binding.promo.visibility = View.VISIBLE - binding.promo.text = detailsState.discount - } else { - binding.promo.visibility = View.GONE - } - - 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 - } -} diff --git a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsScreen.kt b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsScreen.kt new file mode 100644 index 0000000..1c8763d --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsScreen.kt @@ -0,0 +1,142 @@ +package ru.otus.marketsample.details.feature + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import kotlinx.coroutines.launch +import ru.otus.marketsample.common.Discount +import ru.otus.marketsample.navigation.SnackbarController +import ru.otus.marketsample.navigation.SnackbarEvent +import theme.MarketSampleTheme +import theme.black +import theme.purple_500 +import theme.white + +@Composable +fun DetailsScreen( + state: DetailsScreenState, + errorHasShown: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { + drawRect(color = white) + }, + ) { + when { + state.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + state.hasError -> { + LaunchedEffect(Unit) { + scope.launch { + SnackbarController.sendEvent( + SnackbarEvent(state.errorProvider(context)) + ) + } + } + errorHasShown() + } + + else -> { + Content(state) + } + } + } +} + +@Composable +private fun Content( + state: DetailsScreenState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + AsyncImage( + model = state.detailsState.image, + contentDescription = null, + modifier = Modifier.height(300.dp), + contentScale = ContentScale.Crop, + ) + Text( + text = state.detailsState.name, + color = black, + fontSize = 24.sp, + ) + + if (state.detailsState.hasDiscount) { + Discount( + discount = state.detailsState.discount, + style = MaterialTheme.typography.displayLarge, + modifier = Modifier.align(Alignment.End), + ) + } + + Text( + text = state.detailsState.price, + color = purple_500, + fontSize = 18.sp, + modifier = Modifier + .padding(14.dp) + .align(Alignment.End), + ) + Button( + onClick = { }, + shape = RoundedCornerShape(5.dp), + modifier = Modifier + .padding(14.dp) + .align(Alignment.End), + ) { + Text( + text = "ADD TO CART", + fontSize = 18.sp, + ) + } + } +} + +@Composable +@PreviewLightDark +private fun DetailsScreenPreview() { + MarketSampleTheme { + DetailsScreen( + state = DetailsScreenState( + isLoading = false, + detailsState = DetailsState( + name = "Product Name", + price = "2000 руб", + hasDiscount = true, + discount = "-20%", + ), + hasError = false, + ), + errorHasShown = { }, + ) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/details/feature/di/DetailsComponent.kt b/app/src/main/java/ru/otus/marketsample/details/feature/di/DetailsComponent.kt index 8eecd0b..87f620c 100644 --- a/app/src/main/java/ru/otus/marketsample/details/feature/di/DetailsComponent.kt +++ b/app/src/main/java/ru/otus/marketsample/details/feature/di/DetailsComponent.kt @@ -3,8 +3,8 @@ package ru.otus.marketsample.details.feature.di import dagger.BindsInstance import dagger.Component import ru.otus.common.data.products.ProductRepository -import ru.otus.marketsample.details.feature.DetailsFragment import ru.otus.common.di.FeatureScope +import ru.otus.marketsample.details.feature.DetailsViewModelFactory import javax.inject.Named @FeatureScope @@ -19,7 +19,7 @@ interface DetailsComponent { ): DetailsComponent } - fun inject(detailsFragment: DetailsFragment) + fun getDetailsViewModelFactory(): DetailsViewModelFactory } interface DetailsComponentDependencies { diff --git a/app/src/main/java/ru/otus/marketsample/navigation/BottomBar.kt b/app/src/main/java/ru/otus/marketsample/navigation/BottomBar.kt new file mode 100644 index 0000000..535b03b --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/navigation/BottomBar.kt @@ -0,0 +1,67 @@ +package ru.otus.marketsample.navigation + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.currentBackStackEntryAsState +import ru.otus.common.ui.R + +@Composable +fun BottomBar( + navigationState: NavigationState, + modifier: Modifier = Modifier, +) { + val navStackBackEntry by navigationState.navHostController.currentBackStackEntryAsState() + NavigationBar(modifier = modifier) { + NavigationItem.entries.forEach { navigationItem -> + val selected = navStackBackEntry?.destination?.hierarchy?.any { + it.hasRoute(navigationItem.route::class) + } == true + + val color = when { + selected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + } + + NavigationBarItem( + selected = selected, + onClick = { + if (!selected) navigationState.navigateInBottomBarTo(navigationItem.route) + }, + icon = { + Icon( + painter = painterResource(navigationItem.iconResId), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = color, + ) + }, + label = { + Text( + text = navigationItem.title, + color = color, + ) + }, + ) + } + } +} + +enum class NavigationItem( + val route: Route, + val title: String, + val iconResId: Int, +) { + PRODUCTS(Route.Products, "Products", R.drawable.ic_list), + PROMO(Route.Promo, "Promo", R.drawable.ic_discount), +} diff --git a/app/src/main/java/ru/otus/marketsample/navigation/MarketSampleNavHost.kt b/app/src/main/java/ru/otus/marketsample/navigation/MarketSampleNavHost.kt new file mode 100644 index 0000000..2fedc13 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/navigation/MarketSampleNavHost.kt @@ -0,0 +1,101 @@ +package ru.otus.marketsample.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.toRoute +import ru.otus.marketsample.details.feature.DetailsScreen +import ru.otus.marketsample.details.feature.DetailsViewModel +import ru.otus.marketsample.details.feature.di.DaggerDetailsComponent +import ru.otus.marketsample.getApplicationComponent +import ru.otus.marketsample.products.feature.ProductListScreen +import ru.otus.marketsample.products.feature.ProductListViewModel +import ru.otus.marketsample.products.feature.di.DaggerProductListComponent +import ru.otus.marketsample.promo.feature.PromoListScreen +import ru.otus.marketsample.promo.feature.PromoListViewModel +import ru.otus.marketsample.promo.feature.di.DaggerPromoComponent + +@Composable +fun MarketSampleNavHost( + navigationState: NavigationState, + bottomBarState: BarVisibilityState, + modifier: Modifier = Modifier, +) { + NavHost( + navController = navigationState.navHostController, + startDestination = Route.Products, + modifier = modifier, + ) { + navigation(startDestination = ProductsRoute.List) { + composable { + val component = getApplicationComponent() + val productListComponent = remember { + DaggerProductListComponent.factory().create(component) + } + val viewModel = viewModel( + modelClass = ProductListViewModel::class, + viewModelStoreOwner = it, + factory = productListComponent.getProductListViewModelFactory(), + ) + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { bottomBarState.show() } + ProductListScreen( + state = state, + errorHasShown = { viewModel.errorHasShown() }, + isRefreshing = state.isLoading, + onItemClick = { id -> navigationState.navigateToProductDetails(id) }, + onRefresh = viewModel::refresh, + ) + } + + composable { + val detailsRoute = it.toRoute() + val component = getApplicationComponent() + val productListComponent = remember { + DaggerDetailsComponent.factory().create(component, detailsRoute.id) + } + val viewModel = viewModel( + modelClass = DetailsViewModel::class, + viewModelStoreOwner = it, + factory = productListComponent.getDetailsViewModelFactory(), + ) + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { bottomBarState.hide() } + DetailsScreen( + state = state, + errorHasShown = viewModel::errorHasShown, + ) + } + } + + composable { + val component = getApplicationComponent() + val promoListComponent = remember { + DaggerPromoComponent.factory().create(component) + } + val viewModel = viewModel( + modelClass = PromoListViewModel::class, + viewModelStoreOwner = it, + factory = promoListComponent.getPromoViewModelFactory(), + ) + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { bottomBarState.show() } + PromoListScreen( + state = state, + errorHasShown = viewModel::errorHasShown, + isRefreshing = state.isLoading, + onRefresh = viewModel::refresh, + ) + } + } +} diff --git a/app/src/main/java/ru/otus/marketsample/navigation/RememberState.kt b/app/src/main/java/ru/otus/marketsample/navigation/RememberState.kt new file mode 100644 index 0000000..7b1409c --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/navigation/RememberState.kt @@ -0,0 +1,63 @@ +package ru.otus.marketsample.navigation + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +class NavigationState(val navHostController: NavHostController) { + fun navigateInBottomBarTo(route: Route) { + navHostController.navigate(route) { + popUpTo(navHostController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + + fun navigateToProductDetails(id: String) { + navHostController.navigate(ProductsRoute.Details(id)) + } +} + +@Composable +fun rememberNavigationState(controller: NavHostController = rememberNavController()) = + remember { NavigationState(controller) } + + +class BarVisibilityState(defaultVisibility: Boolean = true) { + private val _isVisible = mutableStateOf(defaultVisibility) + val isVisible: Boolean by _isVisible + + fun hide() { + _isVisible.value = false + } + + fun show() { + _isVisible.value = true + } +} + +@Composable +fun rememberBarVisibility(): BarVisibilityState = remember { BarVisibilityState() } + + +data class SnackbarEvent(val message: String) +object SnackbarController { + private val _events = Channel() + val events = _events.receiveAsFlow() + + suspend fun sendEvent(event: SnackbarEvent) { + _events.send(event) + } +} + +@Composable +fun rememberSnackbarHostState(): SnackbarHostState = remember { SnackbarHostState() } diff --git a/app/src/main/java/ru/otus/marketsample/navigation/Route.kt b/app/src/main/java/ru/otus/marketsample/navigation/Route.kt new file mode 100644 index 0000000..343b71c --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/navigation/Route.kt @@ -0,0 +1,21 @@ +package ru.otus.marketsample.navigation + +import kotlinx.serialization.Serializable + +sealed interface Route { + + @Serializable + data object Products : Route + + @Serializable + data object Promo : Route +} + +sealed interface ProductsRoute : Route { + + @Serializable + data object List : ProductsRoute + + @Serializable + data class Details(val id: String) : ProductsRoute +} 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/ProductListItem.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListItem.kt new file mode 100644 index 0000000..7635931 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListItem.kt @@ -0,0 +1,114 @@ +package ru.otus.marketsample.products.feature + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import ru.otus.marketsample.common.Discount +import theme.MarketSampleTheme +import theme.black +import theme.cream +import theme.purple_500 + +@Composable +fun ProductListItem( + productState: ProductState, + onItemClick: (id: String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp) + .height(130.dp) + .clickable { onItemClick.invoke(productState.id) }, + ) { + Box(modifier = Modifier.weight(1f)) { + AsyncImage( + model = productState.image, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(shape = RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop, + ) + + if (productState.discount.isNotBlank()) { + Discount( + discount = productState.discount, + style = MaterialTheme.typography.displaySmall, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(10.dp), + ) + } + } + Column( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = productState.name, + color = black, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.SansSerif, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + Row(modifier = Modifier.align(Alignment.End)) { + Text( + text = productState.price, + color = purple_500, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = Modifier + .background(color = cream, shape = RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } +} + +@Composable +@PreviewLightDark +private fun ProductListItemPreview() { + MarketSampleTheme { + ProductListItem( + productState = ProductState( + id = "0", + name = "Product Name", + image = "", + price = "2000 руб", + hasDiscount = true, + discount = "-20%", + ), + onItemClick = {}, + ) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListScreen.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListScreen.kt new file mode 100644 index 0000000..aa786fe --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListScreen.kt @@ -0,0 +1,95 @@ +package ru.otus.marketsample.products.feature + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewLightDark +import kotlinx.coroutines.launch +import ru.otus.marketsample.navigation.SnackbarController +import ru.otus.marketsample.navigation.SnackbarEvent +import theme.MarketSampleTheme + +@Composable +fun ProductListScreen( + state: ProductsScreenState, + isRefreshing: Boolean, + onRefresh: () -> Unit, + errorHasShown: () -> Unit, + onItemClick: (id: String) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Box(modifier.fillMaxSize()) { + when { + state.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + state.hasError -> { + LaunchedEffect(Unit) { + scope.launch { + SnackbarController.sendEvent( + SnackbarEvent(state.errorProvider(context)) + ) + } + } + errorHasShown() + } + + else -> { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + ) { + LazyColumn { + items(state.productListState, { it.id }) { productState -> + ProductListItem( + productState = productState, + onItemClick = onItemClick, + ) + } + } + } + } + } + } +} + +@Composable +@PreviewLightDark +private fun ProductListScreenPreview() { + MarketSampleTheme { + ProductListScreen( + state = ProductsScreenState( + isLoading = false, + productListState = List(10) { + ProductState( + id = it.toString(), + name = "Name $it", + image = "image", + price = "2000 руб", + hasDiscount = true, + discount = "-20%", + ) + }, + hasError = false, + errorProvider = { "" }, + ), + isRefreshing = false, + onRefresh = { }, + onItemClick = { }, + errorHasShown = {}, + ) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductState.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductState.kt index b500b08..64fd440 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductState.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductState.kt @@ -1,9 +1,11 @@ package ru.otus.marketsample.products.feature import android.content.Context +import androidx.compose.runtime.Immutable typealias ErrorProvider = (Context) -> String +@Immutable data class ProductsScreenState( val isLoading: Boolean = false, val productListState: List = emptyList(), 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..48f205f 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,7 @@ 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 +import ru.otus.marketsample.products.feature.ProductListViewModelFactory @FeatureScope @Component(dependencies = [ProductListComponentDependencies::class]) @@ -17,7 +17,7 @@ interface ProductListComponent { ): ProductListComponent } - fun inject(productListFragment: ProductListFragment) + fun getProductListViewModelFactory(): ProductListViewModelFactory } interface ProductListComponentDependencies { 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/PromoListItem.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListItem.kt new file mode 100644 index 0000000..cd37433 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListItem.kt @@ -0,0 +1,100 @@ +package ru.otus.marketsample.promo.feature + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.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.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import coil.compose.AsyncImage +import theme.MarketSampleTheme +import theme.shadow +import theme.transparent +import theme.white + +@Composable +fun PromoListItem( + promoState: PromoState, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(10.dp), + ) { + AsyncImage( + model = promoState.image, + contentDescription = null, + modifier = Modifier.height(250.dp), + contentScale = ContentScale.Crop, + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .align(Alignment.BottomCenter) + .drawBehind { + drawRect( + brush = Brush.linearGradient( + colors = listOf( + shadow, + transparent, + ), + start = Offset(x = size.width, y = size.height), + end = Offset(x = size.width, y = 0f) + ), + ) + } + .zIndex(1f), + ) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(10.dp) + .zIndex(2f), + ) { + Text( + text = promoState.name, + color = white, + fontSize = 24.sp, + lineHeight = 32.sp, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = promoState.description, + color = white, + fontSize = 14.sp, + lineHeight = 15.sp, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +@PreviewLightDark +private fun PromoListItemPreview() { + MarketSampleTheme { + PromoListItem( + promoState = PromoState( + id = "1", + name = "Name", + description = "Bla bla bla\nBla bla", + image = "image", + ) + ) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListScreen.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListScreen.kt new file mode 100644 index 0000000..e18e321 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListScreen.kt @@ -0,0 +1,87 @@ +package ru.otus.marketsample.promo.feature + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewLightDark +import kotlinx.coroutines.launch +import ru.otus.marketsample.navigation.SnackbarController +import ru.otus.marketsample.navigation.SnackbarEvent +import theme.MarketSampleTheme + +@Composable +fun PromoListScreen( + state: PromoScreenState, + errorHasShown: () -> Unit, + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Box(modifier.fillMaxSize()) { + when { + state.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + state.hasError -> { + LaunchedEffect(Unit) { + scope.launch { + SnackbarController.sendEvent( + SnackbarEvent(state.errorProvider(context)) + ) + } + } + errorHasShown() + } + + else -> { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + ) { + LazyColumn { + items(state.promoListState, { it.id }) { promoState -> + PromoListItem(promoState) + } + } + } + } + } + } +} + +@Composable +@PreviewLightDark +private fun PromoListScreenPreview() { + MarketSampleTheme { + PromoListScreen( + state = PromoScreenState( + isLoading = false, + promoListState = listOf( + PromoState( + id = "1", + name = "Name", + description = "Bla bla bla", + image = "image", + ) + ), + hasError = false, + ), + errorHasShown = { }, + isRefreshing = false, + onRefresh = { }, + ) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoState.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoState.kt index 8a45b7d..c63ea53 100644 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoState.kt +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoState.kt @@ -1,9 +1,11 @@ package ru.otus.marketsample.promo.feature import android.content.Context +import androidx.compose.runtime.Immutable typealias ErrorProvider = (Context) -> String +@Immutable data class PromoScreenState( val isLoading: Boolean = false, val promoListState: List = emptyList(), 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..3c40262 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,7 @@ 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 +import ru.otus.marketsample.promo.feature.PromoListViewModelFactory @FeatureScope @Component(dependencies = [PromoComponentDependencies::class]) @@ -14,7 +14,7 @@ interface PromoComponent { fun create(dependencies: PromoComponentDependencies): PromoComponent } - fun inject(productFragment: PromoListFragment) + fun getPromoViewModelFactory(): PromoListViewModelFactory } interface PromoComponentDependencies { diff --git a/app/src/main/res/drawable/price_background.xml b/app/src/main/res/drawable/price_background.xml deleted file mode 100644 index c8fa67f..0000000 --- a/app/src/main/res/drawable/price_background.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ 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_details.xml b/app/src/main/res/layout/fragment_details.xml deleted file mode 100644 index c37c1f0..0000000 --- a/app/src/main/res/layout/fragment_details.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - -