From 432c672f9eca306122cd343da4b7d4f6c1555719 Mon Sep 17 00:00:00 2001 From: Dmitrii Kaluzhin Date: Sun, 15 Feb 2026 20:17:16 +0300 Subject: [PATCH 1/2] Replace View by Compose --- app/build.gradle | 7 +- .../products/feature/ProductCard.kt | 145 ++++++++++++++++++ .../products/feature/ProductListFragment.kt | 94 +++--------- .../products/feature/ProductsScreen.kt | 63 ++++++++ .../marketsample/promo/feature/PromoCard.kt | 88 +++++++++++ .../promo/feature/PromosScreen.kt | 54 +++++++ gradle/gradle-daemon-jvm.properties | 13 ++ gradle/libs.versions.toml | 13 +- settings.gradle | 3 + 9 files changed, 405 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/ProductCard.kt create mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/ProductsScreen.kt create mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/PromoCard.kt create mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/PromosScreen.kt create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/app/build.gradle b/app/build.gradle index 1cdd96f..0b17bd0 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,6 +35,7 @@ android { } buildFeatures { viewBinding true + compose true } } @@ -55,7 +57,7 @@ dependencies { 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 @@ -63,6 +65,9 @@ dependencies { implementation libs.androidx.datastore implementation libs.androidx.datastore.preferences + implementation platform(libs.compose.bom) + implementation libs.bundles.compose + implementation libs.dagger kapt libs.daggerCompiler diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductCard.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductCard.kt new file mode 100644 index 0000000..4621644 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductCard.kt @@ -0,0 +1,145 @@ +package ru.otus.marketsample.products.feature + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +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.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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage + +@Composable +fun ProductCard( + product: ProductState, + onItemClicked: (String) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable { onItemClicked(product.id) } + .padding(horizontal = 16.dp, vertical = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(130.dp) + ) { + AsyncImage( + model = product.image, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)) + .background(color = Color.LightGray.copy(alpha = 0.3f)), + contentScale = ContentScale.Crop + ) + + if (product.hasDiscount) { + Text( + text = product.discount, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .background( + brush = Brush.linearGradient( + colors = listOf(Color(0xFFBB86FC), Color(0xFF6200EE)), + ), + shape = RoundedCornerShape( + topEnd = 10.dp, + bottomStart = 40.dp, + topStart = 40.dp, + bottomEnd = 40.dp + ) + ) + .border( + 2.dp, + Color.White, + RoundedCornerShape( + topEnd = 10.dp, + bottomStart = 40.dp, + topStart = 40.dp, + bottomEnd = 40.dp + ) + ) + .padding(horizontal = 10.dp, vertical = 4.dp), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = product.name, + modifier = Modifier.fillMaxWidth(), + fontSize = 18.sp, + color = Color.Black, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = product.price, + modifier = Modifier + .align(Alignment.End) + .background( + color = Color(0xFFFFF3E0), + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + color = Color(0xFF6200EE), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ProductCardPreview() { + ProductCard( + product = ProductState( + id = "1", + name = "Product Name", + image = "", + price = "2000 руб", + hasDiscount = true, + discount = "-20%" + ), + onItemClicked = {} + ) +} diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt index 88f7ec0..310b37b 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt @@ -5,28 +5,22 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy 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 @@ -42,76 +36,30 @@ class ProductListFragment : Fragment() { .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), + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme { + val state by viewModel.state.collectAsState() + ProductsScreen( + state = state, + onRefresh = { viewModel.refresh() }, + onItemClicked = { productId -> + requireActivity().findNavController(R.id.nav_host_activity_main) + .navigate( + resId = R.id.action_main_to_details, + args = bundleOf("productId" to productId), + ) + }, + onErrorShown = { viewModel.errorHasShown() } ) - } - ) - 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/ProductsScreen.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductsScreen.kt new file mode 100644 index 0000000..dd47e9c --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductsScreen.kt @@ -0,0 +1,63 @@ +package ru.otus.marketsample.products.feature + +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProductsScreen( + state: ProductsScreenState, + onRefresh: () -> Unit, + onItemClicked: (String) -> Unit, + onErrorShown: () -> Unit +) { + val context = LocalContext.current + + if (state.hasError) { + LaunchedEffect(state.hasError) { + Toast.makeText(context, state.errorProvider(context), Toast.LENGTH_SHORT).show() + onErrorShown() + } + } + + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.productListState) { product -> + ProductCard( + product = product, + onItemClicked = onItemClicked + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 1.dp, + color = Color.LightGray.copy(alpha = 0.5f) + ) + } + } + + if (state.isLoading && state.productListState.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } +} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoCard.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoCard.kt new file mode 100644 index 0000000..e40c029 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoCard.kt @@ -0,0 +1,88 @@ +package ru.otus.marketsample.promo.feature + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage + +@Composable +fun PromoCard( + promo: PromoState, + onItemClicked: (String) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(10.dp) + .clickable { onItemClicked(promo.id) } + ) { + AsyncImage( + model = promo.image, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(250.dp), + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .align(Alignment.BottomCenter) + .background( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color(0xAA000000)), + startY = 0f, + endY = Float.POSITIVE_INFINITY + ) + ) + ) + + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(10.dp) + ) { + Text( + text = promo.name, + color = Color.White, + fontSize = 24.sp + ) + Text( + text = promo.description, + color = Color.White, + fontSize = 14.sp + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun PromoCardPreview() { + PromoCard( + promo = PromoState( + id = "1", + name = "Summer Sale", + description = "Up to 50% off", + image = "" + ), + onItemClicked = {} + ) +} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromosScreen.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromosScreen.kt new file mode 100644 index 0000000..ca0b948 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromosScreen.kt @@ -0,0 +1,54 @@ +package ru.otus.marketsample.promo.feature + +import android.widget.Toast +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.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromosScreen( + state: PromoScreenState, + onRefresh: () -> Unit, + onItemClicked: (String) -> Unit, + onErrorShown: () -> Unit +) { + val context = LocalContext.current + + if (state.hasError) { + LaunchedEffect(state.hasError) { + Toast.makeText(context, state.errorProvider(context), Toast.LENGTH_SHORT).show() + onErrorShown() + } + } + + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.promoListState) { promo -> + PromoCard( + promo = promo, + onItemClicked = onItemClicked + ) + } + } + + if (state.isLoading && state.promoListState.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } +} diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..b3879fd --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/c9346d9c4bd3ae087fba56b027600ff7/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad9bf92..61ccfd9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ serializationJson = "1.9.0" androidXDatastore = "1.1.7" kotlinx-coroutines-rx2 = "1.7.3" dagger = "2.57.1" +composeBom = "2025.01.01" [libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -41,6 +42,7 @@ lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtim 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-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } 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" } @@ -55,8 +57,17 @@ kotlinx-coroutines-rx2 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coro dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } daggerCompiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } + [bundles] network = ["okhttp", "okhttp-logging-interceptor", "retrofit", "retrofit-converter-gson"] +compose = ["compose-ui", "compose-ui-graphics", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3", "compose-runtime", "coil-compose"] [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -64,4 +75,4 @@ kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } androidLibrary = { id = "com.android.library", version.ref = "agp" } - +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/settings.gradle b/settings.gradle index 0022e5a..69fd55c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,6 +5,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { From 822dd2e28b11d8eb5d937c210df544fb880d6f7f Mon Sep 17 00:00:00 2001 From: Dmitrii Kaluzhin Date: Thu, 19 Feb 2026 21:25:32 +0300 Subject: [PATCH 2/2] compose navigation --- .../java/ru/otus/marketsample/MainActivity.kt | 58 ++++++-- .../ru/otus/marketsample/MainNavigation.kt | 135 ++++++++++++++++++ .../details/feature/DetailsFragment.kt | 12 +- .../products/feature/ProductListFragment.kt | 65 --------- .../products/feature/ProductsScreen.kt | 9 +- .../feature/di/ProductListComponent.kt | 4 +- .../promo/feature/PromoListFragment.kt | 106 -------------- .../promo/feature/di/PromoComponent.kt | 4 +- build.gradle | 1 + .../ru/otus/common/di/FindDependencies.kt | 5 +- gradle/libs.versions.toml | 4 +- 11 files changed, 208 insertions(+), 195 deletions(-) create mode 100644 app/src/main/java/ru/otus/marketsample/MainNavigation.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt diff --git a/app/src/main/java/ru/otus/marketsample/MainActivity.kt b/app/src/main/java/ru/otus/marketsample/MainActivity.kt index 7e34aaf..40bb190 100644 --- a/app/src/main/java/ru/otus/marketsample/MainActivity.kt +++ b/app/src/main/java/ru/otus/marketsample/MainActivity.kt @@ -1,26 +1,64 @@ package ru.otus.marketsample import android.os.Bundle +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.compose.ui.graphics.BlendMode.Companion.Screen +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.createGraph +import androidx.navigation.findNavController +import androidx.navigation.toRoute import ru.otus.marketsample.databinding.ActivityMainBinding +import ru.otus.marketsample.details.feature.DetailsViewModel +import ru.otus.marketsample.details.feature.di.DaggerDetailsComponent +import ru.otus.marketsample.products.feature.ProductListViewModel +import ru.otus.marketsample.products.feature.ProductsScreen +import ru.otus.marketsample.products.feature.di.DaggerProductListComponent +import ru.otus.marketsample.promo.feature.PromosScreen -class MainActivity : AppCompatActivity() { +sealed class ScreenRoute(val route: String, val title: String, val icon: ImageVector) { + object Products: ScreenRoute("products_screen", "Products", Icons.Default.Home) + object Promos: ScreenRoute("promos_screen", "Promos", Icons.Default.Settings) +} - private lateinit var binding: ActivityMainBinding +class MainActivity : AppCompatActivity() { 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 { + MaterialTheme { + MainNavigation() + } } } } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/MainNavigation.kt b/app/src/main/java/ru/otus/marketsample/MainNavigation.kt new file mode 100644 index 0000000..229971a --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/MainNavigation.kt @@ -0,0 +1,135 @@ +package ru.otus.marketsample + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.core.os.bundleOf +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.findNavController +import ru.otus.common.di.findDependencies +import ru.otus.marketsample.products.feature.ProductListViewModel +import ru.otus.marketsample.products.feature.ProductsScreen +import ru.otus.marketsample.products.feature.di.DaggerProductListComponent +import ru.otus.marketsample.products.feature.di.ProductListComponentDependencies +import ru.otus.marketsample.promo.feature.PromoListViewModel +import ru.otus.marketsample.promo.feature.PromosScreen +import ru.otus.marketsample.promo.feature.di.DaggerPromoComponent +import ru.otus.marketsample.promo.feature.di.PromoComponentDependencies + +@Composable +fun MainNavigation() { + val navController = rememberNavController() + + Scaffold( + bottomBar = { + NavigationBar { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + listOf(ScreenRoute.Products, ScreenRoute.Promos).forEach { screen -> + NavigationBarItem( + icon = { + Icon(screen.icon, contentDescription = screen.title) + }, + label = { Text(screen.title) }, + selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = ScreenRoute.Products.route, + modifier = Modifier.padding(innerPadding) + ) { + composable(ScreenRoute.Products.route) { + ProductListScreenContent() + } + composable(ScreenRoute.Promos.route) { + PromoListScreenContent() + } + } + } +} + +data class NavigationItem( + val title: String, + val icon: ImageVector, + val route: String +) + +@Composable +fun ProductListScreenContent() { + val dependencies = LocalContext.current.findDependencies() + val component = DaggerProductListComponent.factory().create(dependencies) + val viewModel: ProductListViewModel = viewModel(factory = component.viewModelFactory()) + val state by viewModel.state.collectAsState() + ProductsScreen( + state = state, + onRefresh = { viewModel.refresh() }, + onItemClicked = { productId -> +// requireActivity().findNavController(R.id.nav_host_activity_main) +// .navigate( +// resId = R.id.action_main_to_details, +// args = bundleOf("productId" to productId), +// ) + }, + onErrorShown = { viewModel.errorHasShown() } + ) +} + +@Composable +fun PromoListScreenContent() { + val dependencies = LocalContext.current.findDependencies() + val component = DaggerPromoComponent.factory().create(dependencies) + val viewModel: PromoListViewModel = viewModel(factory = component.viewModelFactory()) + val state by viewModel.state.collectAsState() + + PromosScreen( + state = state, + onRefresh = { viewModel.refresh() }, + onItemClicked = { productId -> +// requireActivity().findNavController(R.id.nav_host_activity_main) +// .navigate( +// resId = R.id.action_main_to_details, +// args = bundleOf("productId" to productId), +// ) + }, + onErrorShown = { viewModel.errorHasShown() } + ) +} \ 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..ed4935c 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 @@ -36,12 +36,12 @@ class DetailsFragment : Fragment() { override fun onAttach(context: Context) { super.onAttach(context) - DaggerDetailsComponent.factory() - .create( - dependencies = findDependencies(), - productId = productId, - ) - .inject(this) +// DaggerDetailsComponent.factory() +// .create( +// dependencies = findDependencies(), +// productId = productId, +// ) +// .inject(this) } override fun onCreateView( 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 310b37b..0000000 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt +++ /dev/null @@ -1,65 +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 androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.navigation.findNavController -import ru.otus.marketsample.MarketSampleApp -import ru.otus.marketsample.R -import ru.otus.marketsample.products.feature.di.DaggerProductListComponent -import javax.inject.Inject - -class ProductListFragment : Fragment() { - - @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 { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - MaterialTheme { - val state by viewModel.state.collectAsState() - ProductsScreen( - state = state, - onRefresh = { viewModel.refresh() }, - onItemClicked = { productId -> - requireActivity().findNavController(R.id.nav_host_activity_main) - .navigate( - resId = R.id.action_main_to_details, - args = bundleOf("productId" to productId), - ) - }, - onErrorShown = { viewModel.errorHasShown() } - ) - } - } - } - } -} 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 index dd47e9c..38b56c8 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductsScreen.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductsScreen.kt @@ -12,11 +12,16 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -24,8 +29,10 @@ fun ProductsScreen( state: ProductsScreenState, onRefresh: () -> Unit, onItemClicked: (String) -> Unit, - onErrorShown: () -> Unit + onErrorShown: () -> Unit, + productsViewModel : ProductListViewModel = viewModel() ) { + val context = LocalContext.current if (state.hasError) { 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..1cb817a 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 viewModelFactory() : 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/di/PromoComponent.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt index b1ad582..2c7fdb9 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 viewModelFactory() : PromoListViewModelFactory } interface PromoComponentDependencies { 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/di/src/main/java/ru/otus/common/di/FindDependencies.kt b/common/di/src/main/java/ru/otus/common/di/FindDependencies.kt index d69c4fb..73e56ce 100644 --- a/common/di/src/main/java/ru/otus/common/di/FindDependencies.kt +++ b/common/di/src/main/java/ru/otus/common/di/FindDependencies.kt @@ -1,9 +1,10 @@ package ru.otus.common.di +import android.content.Context import androidx.fragment.app.Fragment -inline fun Fragment.findDependencies(): T { - return ((activity?.applicationContext as DependenciesProvider).getDependencies() as T) +inline fun Context.findDependencies(): T { + return ((applicationContext as DependenciesProvider).getDependencies() as T) } interface Dependencies diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61ccfd9..e85b459 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ fragment-ktx = "1.8.9" lifecycle-runtime-ktx = "2.9.3" navigation-fragment-ktx = "2.9.3" navigation-ui-ktx = "2.9.3" +navigationCompose = "2.8.5" coil = "2.7.0" gson = "2.13.1" okhttp = "4.12.0" @@ -41,6 +42,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" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } @@ -67,7 +69,7 @@ compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } [bundles] network = ["okhttp", "okhttp-logging-interceptor", "retrofit", "retrofit-converter-gson"] -compose = ["compose-ui", "compose-ui-graphics", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3", "compose-runtime", "coil-compose"] +compose = ["compose-ui", "compose-ui-graphics", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3", "compose-runtime", "coil-compose", "navigation-compose"] [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }