From 8a6bf0558bc4565733ca78035804c06f38ba4c56 Mon Sep 17 00:00:00 2001 From: YURY TILMAN Date: Sun, 10 May 2026 19:07:50 +0300 Subject: [PATCH] Home task implement: migrate app to Jetpack Compose. --- app/build.gradle | 33 +-- .../java/ru/otus/marketsample/MainActivity.kt | 28 +-- .../java/ru/otus/marketsample/MainFragment.kt | 49 ---- .../ru/otus/marketsample/MarketSampleApp.kt | 14 +- .../details/feature/DetailsFragment.kt | 125 ---------- .../details/feature/DetailsScreen.kt | 163 +++++++++++++ .../details/feature/DetailsState.kt | 9 +- .../details/feature/DetailsStateFactory.kt | 1 - .../details/feature/DetailsViewModel.kt | 13 +- .../feature/DetailsViewModelFactory.kt | 36 --- .../details/feature/di/DetailsComponent.kt | 27 -- .../ru/otus/marketsample/di/AppComponent.kt | 29 --- .../ru/otus/marketsample/di/DataModule.kt | 6 +- .../ru/otus/marketsample/di/NetworkModule.kt | 3 + .../otus/marketsample/di/RepositoryModule.kt | 24 ++ .../products/feature/ProductListFragment.kt | 117 --------- .../products/feature/ProductListScreen.kt | 230 ++++++++++++++++++ .../products/feature/ProductListViewModel.kt | 14 +- .../feature/ProductListViewModelFactory.kt | 32 --- .../products/feature/ProductState.kt | 12 +- .../products/feature/ProductStateFactory.kt | 4 +- .../products/feature/adapter/ProductHolder.kt | 32 --- .../feature/adapter/ProductsAdapter.kt | 44 ---- .../feature/di/ProductListComponent.kt | 26 -- .../promo/feature/PromoListFragment.kt | 106 -------- .../promo/feature/PromoListScreen.kt | 180 ++++++++++++++ .../promo/feature/PromoListViewModel.kt | 11 +- .../feature/PromoListViewModelFactory.kt | 32 --- .../marketsample/promo/feature/PromoState.kt | 12 +- .../promo/feature/PromoStateFactory.kt | 2 - .../promo/feature/adapter/PromoAdapter.kt | 40 --- .../promo/feature/adapter/PromoHolder.kt | 17 -- .../promo/feature/di/PromoComponent.kt | 22 -- .../java/ru/otus/marketsample/ui/UiText.kt | 30 +++ .../ui/navigation/MainBottomBar.kt | 101 ++++++++ .../marketsample/ui/navigation/Navigation.kt | 94 +++++++ .../otus/marketsample/ui/navigation/Screen.kt | 30 +++ .../ru/otus/marketsample/ui/theme/Theme.kt | 23 ++ app/src/main/res/layout/activity_main.xml | 16 -- app/src/main/res/layout/fragment_details.xml | 78 ------ app/src/main/res/layout/fragment_main.xml | 32 --- .../main/res/layout/fragment_product_list.xml | 34 --- .../main/res/layout/fragment_promo_list.xml | 36 --- app/src/main/res/layout/item_product.xml | 85 ------- app/src/main/res/layout/item_promo.xml | 48 ---- .../navigation/main_activity_navigation.xml | 27 -- .../navigation/main_fragment_navigation.xml | 20 -- app/src/main/res/values/strings.xml | 3 + build.gradle | 4 +- common/data/products/build.gradle | 8 +- .../common/data/products/ProductRepository.kt | 40 +-- .../data/products/ProductRepositoryImpl.kt | 41 ++++ common/data/promo/build.gradle | 8 +- .../otus/common/data/promo/PromoRepository.kt | 38 +-- .../common/data/promo/PromoRepositoryImpl.kt | 40 +++ common/di/build.gradle | 8 +- common/formatters/build.gradle | 8 +- common/ui/build.gradle | 4 - gradle/libs.versions.toml | 90 ++++--- gradle/wrapper/gradle-wrapper.properties | 2 +- 60 files changed, 1135 insertions(+), 1306 deletions(-) delete mode 100644 app/src/main/java/ru/otus/marketsample/MainFragment.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/details/feature/DetailsFragment.kt create mode 100644 app/src/main/java/ru/otus/marketsample/details/feature/DetailsScreen.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/details/feature/DetailsViewModelFactory.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/details/feature/di/DetailsComponent.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/di/AppComponent.kt create mode 100644 app/src/main/java/ru/otus/marketsample/di/RepositoryModule.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/ProductListFragment.kt create mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/ProductListScreen.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModelFactory.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/adapter/ProductHolder.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/adapter/ProductsAdapter.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/products/feature/di/ProductListComponent.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt create mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/PromoListScreen.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModelFactory.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoAdapter.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoHolder.kt delete mode 100644 app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt create mode 100644 app/src/main/java/ru/otus/marketsample/ui/UiText.kt create mode 100644 app/src/main/java/ru/otus/marketsample/ui/navigation/MainBottomBar.kt create mode 100644 app/src/main/java/ru/otus/marketsample/ui/navigation/Navigation.kt create mode 100644 app/src/main/java/ru/otus/marketsample/ui/navigation/Screen.kt create mode 100644 app/src/main/java/ru/otus/marketsample/ui/theme/Theme.kt delete mode 100644 app/src/main/res/layout/activity_main.xml delete mode 100644 app/src/main/res/layout/fragment_details.xml delete mode 100644 app/src/main/res/layout/fragment_main.xml delete mode 100644 app/src/main/res/layout/fragment_product_list.xml delete mode 100644 app/src/main/res/layout/fragment_promo_list.xml delete mode 100644 app/src/main/res/layout/item_product.xml delete mode 100644 app/src/main/res/layout/item_promo.xml delete mode 100644 app/src/main/res/navigation/main_activity_navigation.xml delete mode 100644 app/src/main/res/navigation/main_fragment_navigation.xml create mode 100644 common/data/products/src/main/java/ru/otus/common/data/products/ProductRepositoryImpl.kt create mode 100644 common/data/promo/src/main/java/ru/otus/common/data/promo/PromoRepositoryImpl.kt diff --git a/app/build.gradle b/app/build.gradle index 1cdd96f..bb068f6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,8 +1,9 @@ plugins { alias(libs.plugins.androidApplication) - alias(libs.plugins.kotlinAndroid) alias(libs.plugins.kotlinxSerialization) - alias(libs.plugins.kapt) + alias(libs.plugins.ksp) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.hilt) } android { @@ -29,11 +30,15 @@ android { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = '17' + composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + metricsDestination = layout.buildDirectory.dir("compose_compiler") + enableStrongSkippingMode = true + stabilityConfigurationFile = rootProject.file("compose-stability.conf") } buildFeatures { - viewBinding true + viewBinding false // так как XML-лейаутов больше нет + compose true } } @@ -47,14 +52,8 @@ dependencies { 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.gson implementation libs.bundles.network @@ -63,10 +62,16 @@ dependencies { implementation libs.androidx.datastore implementation libs.androidx.datastore.preferences - implementation libs.dagger - kapt libs.daggerCompiler + implementation platform(libs.androidx.compose.bom) + implementation libs.bundles.compose + implementation libs.kotlinx.collections.immutable + debugImplementation libs.androidx.compose.ui.tooling + + implementation libs.hilt.android + ksp libs.hilt.compiler + implementation libs.hilt.navigation.compose testImplementation libs.junit androidTestImplementation libs.androidx.test.ext.junit androidTestImplementation libs.espresso.core -} \ 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..75e6f9a 100644 --- a/app/src/main/java/ru/otus/marketsample/MainActivity.kt +++ b/app/src/main/java/ru/otus/marketsample/MainActivity.kt @@ -1,26 +1,24 @@ 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 +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.marketsample.ui.navigation.MainScreen +import ru.otus.marketsample.ui.theme.MarketSampleTheme -class MainActivity : AppCompatActivity() { - - private lateinit var binding: ActivityMainBinding +@AndroidEntryPoint +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 + + setContent { + MarketSampleTheme { + MainScreen() + } } } -} \ 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..45e19b7 100644 --- a/app/src/main/java/ru/otus/marketsample/MarketSampleApp.kt +++ b/app/src/main/java/ru/otus/marketsample/MarketSampleApp.kt @@ -1,15 +1,7 @@ package ru.otus.marketsample import android.app.Application -import ru.otus.marketsample.di.AppComponent -import ru.otus.marketsample.di.DaggerAppComponent -import ru.otus.common.di.Dependencies -import ru.otus.common.di.DependenciesProvider +import dagger.hilt.android.HiltAndroidApp -class MarketSampleApp: Application(), DependenciesProvider { - val appComponent: AppComponent = DaggerAppComponent.factory().create(this) - - override fun getDependencies(): Dependencies { - return appComponent - } -} +@HiltAndroidApp +class MarketSampleApp : Application() 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..f055e15 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsScreen.kt @@ -0,0 +1,163 @@ +package ru.otus.marketsample.details.feature + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import android.widget.Toast +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import ru.otus.marketsample.R + +/** + * Экран деталей продукта + * + * @param viewModel ViewModel для управления состоянием экрана + * @param modifier Модификатор для настройки макета экрана + */ +@Composable +fun DetailsScreen( + modifier: Modifier = Modifier, + viewModel: DetailsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val errorText = state.errorMessage?.asString() ?: stringResource(R.string.error_unknown) + + LaunchedEffect(state.hasError) { + if (state.hasError) { + Toast.makeText(context, errorText, Toast.LENGTH_SHORT).show() + viewModel.errorHasShown() + } + } + + DetailsScreenContent( + state = state, + modifier = modifier + ) +} + +/** + * Вспомогательный компонент для отображения контента деталей продукта + * + * @param state Состояние экрана + * @param modifier Модификатор + */ +@Composable +fun DetailsScreenContent( + state: DetailsScreenState, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.fillMaxSize()) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + val details = state.detailsState + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .background(Color.White) + ) { + AsyncImage( + model = details.image, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + contentScale = ContentScale.Crop + ) + + Text( + text = details.name, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.headlineMedium, + color = Color.Black, + fontSize = 24.sp + ) + + if (details.hasDiscount) { + Text( + text = details.discount, + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp) + .background(Color(0xFFFF0000), RoundedCornerShape(4.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + color = Color.White, + fontSize = 20.sp + ) + } + + Text( + text = stringResource(R.string.price_with_arg, details.price), + modifier = Modifier + .align(Alignment.End) + .padding(16.dp), + color = Color(0xFF6200EE), + fontSize = 18.sp + ) + + Text( + text = details.description, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = Color.DarkGray, + fontSize = 16.sp + ) + + Button( + onClick = { /* TODO: Add to cart */ }, + modifier = Modifier + .align(Alignment.End) + .padding(16.dp), + shape = RoundedCornerShape(8.dp) + ) { + Text(text = stringResource(R.string.action_add_to_cart), fontSize = 18.sp) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DetailsScreenPreview() { + val state = DetailsScreenState( + detailsState = DetailsState( + id = "1", + name = "Смартфон Samsung Galaxy S23", + description = "Флагманский смартфон с отличной камерой и мощным процессором.", + image = "", + price = "75 000", + hasDiscount = true, + discount = "-10%" + ) + ) + MaterialTheme { + DetailsScreenContent(state = state) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsState.kt b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsState.kt index 62a4a15..5dcd0a8 100644 --- a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsState.kt +++ b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsState.kt @@ -1,20 +1,23 @@ package ru.otus.marketsample.details.feature -import android.content.Context +import androidx.compose.runtime.Immutable -typealias ErrorProvider = (Context) -> String +import ru.otus.marketsample.ui.UiText +@Immutable data class DetailsScreenState( val isLoading: Boolean = false, val detailsState: DetailsState = DetailsState(), val hasError: Boolean = false, - val errorProvider: ErrorProvider = { "" }, + val errorMessage: UiText? = null, ) +@Immutable data class DetailsState( val id: String = "", val name: String = "", val image: String = "", + val description: String = "", val price: String = "", val hasDiscount: Boolean = false, val discount: String = "", diff --git a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsStateFactory.kt b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsStateFactory.kt index 94053f5..0ce7a15 100644 --- a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsStateFactory.kt +++ b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsStateFactory.kt @@ -5,7 +5,6 @@ import ru.otus.common.di.FeatureScope import ru.otus.common.formatters.PriceFormatter import javax.inject.Inject -@FeatureScope class DetailsStateFactory @Inject constructor( private val priceFormatter: PriceFormatter, ) { diff --git a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsViewModel.kt b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsViewModel.kt index 9d03698..44ab87c 100644 --- a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsViewModel.kt +++ b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsViewModel.kt @@ -1,7 +1,9 @@ package ru.otus.marketsample.details.feature +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,13 +17,18 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import ru.otus.marketsample.details.domain.ConsumeProductDetailsUseCase import ru.otus.marketsample.R +import ru.otus.marketsample.ui.UiText +import javax.inject.Inject -class DetailsViewModel( +@HiltViewModel +class DetailsViewModel @Inject constructor( private val consumeProductDetailsUseCase: ConsumeProductDetailsUseCase, private val detailsStateFactory: DetailsStateFactory, - private val productId: String, + savedStateHandle: SavedStateHandle, ) : ViewModel() { + private val productId: String = checkNotNull(savedStateHandle["productId"]) + private val _state = MutableStateFlow(DetailsScreenState()) val state: StateFlow = _state.asStateFlow() @@ -48,7 +55,7 @@ class DetailsViewModel( _state.update { screenState -> screenState.copy( hasError = true, - errorProvider = { context -> context.getString(R.string.error_wile_loading_data) } + errorMessage = UiText.StringResource(R.string.error_wile_loading_data) ) } } diff --git a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsViewModelFactory.kt b/app/src/main/java/ru/otus/marketsample/details/feature/DetailsViewModelFactory.kt deleted file mode 100644 index 1465975..0000000 --- a/app/src/main/java/ru/otus/marketsample/details/feature/DetailsViewModelFactory.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ru.otus.marketsample.details.feature - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.CreationExtras -import ru.otus.marketsample.details.domain.ConsumeProductDetailsUseCase -import ru.otus.common.di.FeatureScope -import javax.inject.Inject -import javax.inject.Named - -@FeatureScope -class DetailsViewModelFactory @Inject constructor( - private val consumeProductDetailsUseCase: ConsumeProductDetailsUseCase, - private val detailsStateFactory: DetailsStateFactory, - @Named("productId") - private val productId: String, -) : - ViewModelProvider.Factory { - - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - when { - modelClass.isAssignableFrom(DetailsViewModel::class.java) -> { - @Suppress("UNCHECKED_CAST") - return DetailsViewModel( - consumeProductDetailsUseCase = consumeProductDetailsUseCase, - detailsStateFactory = detailsStateFactory, - productId = productId, - ) as T - } - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} 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 deleted file mode 100644 index 8eecd0b..0000000 --- a/app/src/main/java/ru/otus/marketsample/details/feature/di/DetailsComponent.kt +++ /dev/null @@ -1,27 +0,0 @@ -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 javax.inject.Named - -@FeatureScope -@Component(dependencies = [DetailsComponentDependencies::class]) -interface DetailsComponent { - - @Component.Factory - interface Factory { - fun create( - dependencies: DetailsComponentDependencies, - @BindsInstance @Named("productId") productId: String, - ): DetailsComponent - } - - fun inject(detailsFragment: DetailsFragment) -} - -interface DetailsComponentDependencies { - fun getProductRepository(): ProductRepository -} diff --git a/app/src/main/java/ru/otus/marketsample/di/AppComponent.kt b/app/src/main/java/ru/otus/marketsample/di/AppComponent.kt deleted file mode 100644 index 2b95681..0000000 --- a/app/src/main/java/ru/otus/marketsample/di/AppComponent.kt +++ /dev/null @@ -1,29 +0,0 @@ -package ru.otus.marketsample.di - -import android.content.Context -import dagger.BindsInstance -import dagger.Component -import ru.otus.marketsample.details.feature.di.DetailsComponentDependencies -import ru.otus.marketsample.products.feature.di.ProductListComponentDependencies -import ru.otus.marketsample.promo.feature.di.PromoComponentDependencies -import ru.otus.common.di.Dependencies -import javax.inject.Singleton - -@Singleton -@Component( - modules = [ - NetworkModule::class, - DataModule::class, - ] -) -interface AppComponent: - Dependencies, - DetailsComponentDependencies, - PromoComponentDependencies, - ProductListComponentDependencies -{ - @Component.Factory - interface Factory { - fun create(@BindsInstance applicationContext: Context): AppComponent - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/di/DataModule.kt b/app/src/main/java/ru/otus/marketsample/di/DataModule.kt index cf315f7..c91249e 100644 --- a/app/src/main/java/ru/otus/marketsample/di/DataModule.kt +++ b/app/src/main/java/ru/otus/marketsample/di/DataModule.kt @@ -6,6 +6,9 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import dagger.Module import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import retrofit2.Retrofit @@ -14,6 +17,7 @@ import ru.otus.common.data.promo.PromoApiService import javax.inject.Singleton @Module +@InstallIn(SingletonComponent::class) object DataModule { @Singleton @@ -43,7 +47,7 @@ object DataModule { @Singleton @Provides fun provideDataStoreOfPreferences( - applicationContext: Context + @ApplicationContext applicationContext: Context ): DataStore { return applicationContext.appDataStore } diff --git a/app/src/main/java/ru/otus/marketsample/di/NetworkModule.kt b/app/src/main/java/ru/otus/marketsample/di/NetworkModule.kt index ae606c5..49c5163 100644 --- a/app/src/main/java/ru/otus/marketsample/di/NetworkModule.kt +++ b/app/src/main/java/ru/otus/marketsample/di/NetworkModule.kt @@ -2,6 +2,8 @@ package ru.otus.marketsample.di import dagger.Module import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -12,6 +14,7 @@ import javax.inject.Singleton private const val BASE_URL = "https://otus-android.github.io/" @Module +@InstallIn(SingletonComponent::class) object NetworkModule { @Provides diff --git a/app/src/main/java/ru/otus/marketsample/di/RepositoryModule.kt b/app/src/main/java/ru/otus/marketsample/di/RepositoryModule.kt new file mode 100644 index 0000000..b970535 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/di/RepositoryModule.kt @@ -0,0 +1,24 @@ +package ru.otus.marketsample.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ru.otus.common.data.products.ProductRepository +import ru.otus.common.data.products.ProductRepositoryImpl +import ru.otus.common.data.promo.PromoRepository +import ru.otus.common.data.promo.PromoRepositoryImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface RepositoryModule { + + @Singleton + @Binds + fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository + + @Singleton + @Binds + fun bindPromoRepository(impl: PromoRepositoryImpl): PromoRepository +} 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/ProductListScreen.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListScreen.kt new file mode 100644 index 0000000..19e8c70 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListScreen.kt @@ -0,0 +1,230 @@ +package ru.otus.marketsample.products.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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import android.widget.Toast +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import kotlinx.collections.immutable.persistentListOf +import ru.otus.marketsample.R + +/** + * Главный экран списка продуктов + * + * @param viewModel ViewModel для управления состоянием экрана + * @param onProductClick Обработчик нажатия на продукт + * @param modifier Модификатор для настройки макета экрана + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProductListScreen( + onProductClick: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: ProductListViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val errorText = state.errorMessage?.asString() ?: stringResource(R.string.error_unknown) + + LaunchedEffect(state.hasError) { + if (state.hasError) { + Toast.makeText(context, errorText, Toast.LENGTH_SHORT).show() + viewModel.errorHasShown() + } + } + + ProductListScreenContent( + state = state, + onRefresh = { viewModel.refresh() }, + onProductClick = onProductClick, + modifier = modifier + ) +} + +/** + * Вспомогательный компонент для отображения контента списка продуктов + * + * @param state Состояние экрана + * @param onRefresh Обработчик обновления списка + * @param onProductClick Обработчик нажатия на продукт + * @param modifier Модификатор + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProductListScreenContent( + state: ProductsScreenState, + onRefresh: () -> Unit, + onProductClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize() + ) { + if (state.productListState.isEmpty() && state.isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(state.productListState, key = { it.id }) { product -> + ProductItem( + product = product, + onClick = { onProductClick(product.id) } + ) + } + } + } + } +} + +/** + * Компонент элемента списка продуктов + * + * @param product Состояние продукта для отображения + * @param onClick Обработчик нажатия на элемент + */ +@Composable +fun ProductItem( + product: ProductState, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Изображение и промо-метка + Box( + modifier = Modifier + .weight(1f) + .height(130.dp) + ) { + AsyncImage( + model = product.image, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + + if (product.hasDiscount) { + Text( + text = product.discount, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .background(Color(0xFFFF0000), RoundedCornerShape(4.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация о продукте + Column( + modifier = Modifier + .weight(1f) + .height(130.dp) + .padding(vertical = 4.dp) + ) { + Text( + text = product.name, + style = MaterialTheme.typography.titleMedium, + fontSize = 18.sp, + color = Color.Black, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = stringResource(R.string.price_with_arg, product.price), + modifier = Modifier + .align(Alignment.End) + .background(Color(0xFFF3E5F5), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + color = Color(0xFF6200EE), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ProductListScreenPreview() { + val state = ProductsScreenState( + productListState = persistentListOf( + ProductState( + id = "1", + name = "Смартфон Samsung Galaxy S23", + image = "", + price = "75 000", + hasDiscount = true, + discount = "-10%" + ), + ProductState( + id = "2", + name = "Наушники Sony WH-1000XM5", + image = "", + price = "35 000", + hasDiscount = false, + discount = "" + ) + ) + ) + MaterialTheme { + ProductListScreenContent( + state = state, + onRefresh = {}, + onProductClick = {} + ) + } +} diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModel.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModel.kt index ce33e63..333de68 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModel.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModel.kt @@ -2,20 +2,24 @@ package ru.otus.marketsample.products.feature import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update -import ru.otus.marketsample.products.domain.ConsumeProductsUseCase import ru.otus.marketsample.R +import ru.otus.marketsample.products.domain.ConsumeProductsUseCase +import ru.otus.marketsample.ui.UiText +import javax.inject.Inject -class ProductListViewModel( +@HiltViewModel +class ProductListViewModel @Inject constructor( private val consumeProductsUseCase: ConsumeProductsUseCase, private val productStateFactory: ProductStateFactory, ) : ViewModel() { @@ -38,7 +42,7 @@ class ProductListViewModel( _state.update { screenState -> screenState.copy( isLoading = false, - productListState = productListState, + productListState = productListState.toImmutableList(), ) } } @@ -46,7 +50,7 @@ class ProductListViewModel( _state.update { screenState -> screenState.copy( hasError = true, - errorProvider = { context -> context.getString(R.string.error_wile_loading_data) } + errorMessage = UiText.StringResource(R.string.error_wile_loading_data) ) } } diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModelFactory.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModelFactory.kt deleted file mode 100644 index 55eaeb7..0000000 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductListViewModelFactory.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ru.otus.marketsample.products.feature - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.CreationExtras -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.products.domain.ConsumeProductsUseCase -import javax.inject.Inject - -@FeatureScope -class ProductListViewModelFactory @Inject constructor( - private val consumeProductsUseCase: ConsumeProductsUseCase, - private val productStateFactory: ProductStateFactory, -) : - ViewModelProvider.Factory { - - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - when { - modelClass.isAssignableFrom(ProductListViewModel::class.java) -> { - @Suppress("UNCHECKED_CAST") - return ProductListViewModel( - consumeProductsUseCase = consumeProductsUseCase, - productStateFactory = productStateFactory, - ) as T - } - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductState.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductState.kt index b500b08..7b2f5a6 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,16 +1,20 @@ package ru.otus.marketsample.products.feature -import android.content.Context +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf -typealias ErrorProvider = (Context) -> String +import ru.otus.marketsample.ui.UiText +@Immutable data class ProductsScreenState( val isLoading: Boolean = false, - val productListState: List = emptyList(), + val productListState: ImmutableList = persistentListOf(), val hasError: Boolean = false, - val errorProvider: ErrorProvider = { "" }, + val errorMessage: UiText? = null, ) +@Immutable data class ProductState( val id: String, val name: String, diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/ProductStateFactory.kt b/app/src/main/java/ru/otus/marketsample/products/feature/ProductStateFactory.kt index 51f0490..4c10dff 100644 --- a/app/src/main/java/ru/otus/marketsample/products/feature/ProductStateFactory.kt +++ b/app/src/main/java/ru/otus/marketsample/products/feature/ProductStateFactory.kt @@ -1,12 +1,10 @@ package ru.otus.marketsample.products.feature -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.products.domain.Product import ru.otus.common.formatters.DiscountFormatter import ru.otus.common.formatters.PriceFormatter +import ru.otus.marketsample.products.domain.Product import javax.inject.Inject -@FeatureScope class ProductStateFactory @Inject constructor( private val discountFormatter: DiscountFormatter, private val priceFormatter: PriceFormatter, diff --git a/app/src/main/java/ru/otus/marketsample/products/feature/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 deleted file mode 100644 index 2b4c9fd..0000000 --- a/app/src/main/java/ru/otus/marketsample/products/feature/di/ProductListComponent.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ru.otus.marketsample.products.feature.di - -import dagger.Component -import ru.otus.common.data.products.ProductRepository -import ru.otus.common.data.promo.PromoRepository -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.products.feature.ProductListFragment - -@FeatureScope -@Component(dependencies = [ProductListComponentDependencies::class]) -interface ProductListComponent { - - @Component.Factory - interface Factory { - fun create( - dependencies: ProductListComponentDependencies, - ): ProductListComponent - } - - fun inject(productListFragment: ProductListFragment) -} - -interface ProductListComponentDependencies { - fun getPromoRepository(): PromoRepository - fun getProductRepository(): ProductRepository -} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt deleted file mode 100644 index 2e4f533..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListFragment.kt +++ /dev/null @@ -1,106 +0,0 @@ -package ru.otus.marketsample.promo.feature - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.launch -import ru.otus.common.di.findDependencies -import ru.otus.marketsample.databinding.FragmentPromoListBinding -import ru.otus.marketsample.promo.feature.adapter.PromoAdapter -import ru.otus.marketsample.promo.feature.di.DaggerPromoComponent -import javax.inject.Inject - -class PromoListFragment : Fragment() { - - private var _binding: FragmentPromoListBinding? = null - private val binding get() = _binding!! - - @Inject - lateinit var adapter: PromoAdapter - - @Inject - lateinit var factory: PromoListViewModelFactory - - private val viewModel: PromoListViewModel by viewModels { factory } - - override fun onAttach(context: Context) { - super.onAttach(context) - - DaggerPromoComponent.factory() - .create(dependencies = findDependencies()) - .inject(this) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentPromoListBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.recyclerView.adapter = adapter - binding.recyclerView.layoutManager = LinearLayoutManager(context) - - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh() - } - - subscribeUI() - } - - private fun subscribeUI() { - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - viewModel.state.collect { state -> - when { - state.isLoading -> showLoading() - state.hasError -> { - Toast.makeText( - requireContext(), - "Error wile loading data", - Toast.LENGTH_SHORT - ).show() - - viewModel.errorHasShown() - } - - else -> showPromoList(promoListState = state.promoListState) - } - } - } - } - } - } - - private fun showPromoList(promoListState: List) { - binding.progress.visibility = View.GONE - binding.recyclerView.visibility = View.VISIBLE - adapter.submitList(promoListState) - binding.swipeRefreshLayout.isRefreshing = false - } - - private fun showLoading() { - binding.progress.visibility = View.VISIBLE - binding.recyclerView.visibility = View.GONE - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListScreen.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListScreen.kt new file mode 100644 index 0000000..6821039 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListScreen.kt @@ -0,0 +1,180 @@ +package ru.otus.marketsample.promo.feature + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import kotlinx.collections.immutable.persistentListOf +import ru.otus.marketsample.R + +/** + * Главный экран списка промо-акций + * + * @param viewModel ViewModel для управления состоянием экрана + * @param modifier Модификатор для настройки макета экрана + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromoListScreen( + modifier: Modifier = Modifier, + viewModel: PromoListViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val errorText = state.errorMessage?.asString() ?: stringResource(R.string.error_unknown) + + LaunchedEffect(state.hasError) { + if (state.hasError) { + Toast.makeText(context, errorText, Toast.LENGTH_SHORT).show() + viewModel.errorHasShown() + } + } + + PromoListScreenContent( + state = state, + onRefresh = { viewModel.refresh() }, + modifier = modifier + ) +} + +/** + * Вспомогательный компонент для отображения контента списка промо-акций + * + * @param state Состояние экрана + * @param onRefresh Обработчик обновления списка + * @param modifier Модификатор + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromoListScreenContent( + state: PromoScreenState, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize() + ) { + if (state.promoListState.isEmpty() && state.isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(state.promoListState, key = { it.id }) { promo -> + PromoItem(promo = promo) + } + } + } + } +} + +/** + * Компонент элемента списка промо-акций + * + * @param promo Состояние промо-акции для отображения + */ +@Composable +fun PromoItem( + promo: PromoState, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(10.dp) + .height(250.dp) + ) { + AsyncImage( + model = promo.image, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Градиент для текста + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .align(Alignment.BottomStart) + .background( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.7f)) + ) + ) + ) + + // Контент промо-акции + 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 +private fun PromoListScreenPreview() { + val state = PromoScreenState( + promoListState = persistentListOf( + PromoState( + id = "1", + name = "Летняя распродажа", + description = "Скидки до 50% на все товары", + image = "" + ), + PromoState( + id = "2", + name = "Кэшбэк 10%", + description = "Получите кэшбэк при оплате картой", + image = "" + ) + ) + ) + PromoListScreenContent( + state = state, + onRefresh = {} + ) +} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModel.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModel.kt index 6343012..3cebb76 100644 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModel.kt +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModel.kt @@ -2,6 +2,7 @@ package ru.otus.marketsample.promo.feature import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -11,10 +12,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update +import kotlinx.collections.immutable.toImmutableList import ru.otus.marketsample.promo.domain.ConsumePromosUseCase import ru.otus.marketsample.R +import ru.otus.marketsample.ui.UiText +import javax.inject.Inject -class PromoListViewModel( +@HiltViewModel +class PromoListViewModel @Inject constructor( private val promoStateFactory: PromoStateFactory, private val consumePromosUseCase: ConsumePromosUseCase, ) : ViewModel() { @@ -38,7 +43,7 @@ class PromoListViewModel( _state.update { screenState -> screenState.copy( isLoading = false, - promoListState = promoListState, + promoListState = promoListState.toImmutableList(), ) } } @@ -46,7 +51,7 @@ class PromoListViewModel( _state.update { screenState -> screenState.copy( hasError = true, - errorProvider = { context -> context.getString(R.string.error_wile_loading_data) } + errorMessage = UiText.StringResource(R.string.error_wile_loading_data) ) } } diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModelFactory.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModelFactory.kt deleted file mode 100644 index f397c06..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoListViewModelFactory.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ru.otus.marketsample.promo.feature - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.CreationExtras -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.promo.domain.ConsumePromosUseCase -import javax.inject.Inject - -@FeatureScope -class PromoListViewModelFactory @Inject constructor( - private val promoStateFactory: PromoStateFactory, - private val consumePromosUseCase: ConsumePromosUseCase, -) : - ViewModelProvider.Factory { - - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - when { - modelClass.isAssignableFrom(PromoListViewModel::class.java) -> { - @Suppress("UNCHECKED_CAST") - return PromoListViewModel( - promoStateFactory = promoStateFactory, - consumePromosUseCase = consumePromosUseCase, - ) as T - } - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoState.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoState.kt index 8a45b7d..ba27e4a 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,16 +1,20 @@ package ru.otus.marketsample.promo.feature -import android.content.Context +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf -typealias ErrorProvider = (Context) -> String +import ru.otus.marketsample.ui.UiText +@Immutable data class PromoScreenState( val isLoading: Boolean = false, - val promoListState: List = emptyList(), + val promoListState: ImmutableList = persistentListOf(), val hasError: Boolean = false, - val errorProvider: ErrorProvider = { "" }, + val errorMessage: UiText? = null, ) +@Immutable data class PromoState( val id: String, val name: String, diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoStateFactory.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoStateFactory.kt index 6d350a6..a8f32de 100644 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/PromoStateFactory.kt +++ b/app/src/main/java/ru/otus/marketsample/promo/feature/PromoStateFactory.kt @@ -1,10 +1,8 @@ package ru.otus.marketsample.promo.feature -import ru.otus.common.di.FeatureScope import ru.otus.marketsample.promo.domain.Promo import javax.inject.Inject -@FeatureScope class PromoStateFactory @Inject constructor() { fun map(promo: Promo): PromoState { return PromoState( diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoAdapter.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoAdapter.kt deleted file mode 100644 index 0f6b562..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoAdapter.kt +++ /dev/null @@ -1,40 +0,0 @@ -package ru.otus.marketsample.promo.feature.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.databinding.ItemPromoBinding -import ru.otus.marketsample.promo.feature.PromoState -import javax.inject.Inject - -@FeatureScope -class PromoAdapter @Inject constructor() : ListAdapter(DiffCallback()) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PromoHolder { - return PromoHolder( - ItemPromoBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - ) - } - - override fun onBindViewHolder(holder: PromoHolder, position: Int) { - val entity = getItem(position) - entity?.let { - holder.bind(entity) - } - } -} - -private class DiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: PromoState, newItem: PromoState): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: PromoState, newItem: PromoState): Boolean { - return oldItem == newItem - } -} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoHolder.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoHolder.kt deleted file mode 100644 index 5d08f5d..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/adapter/PromoHolder.kt +++ /dev/null @@ -1,17 +0,0 @@ -package ru.otus.marketsample.promo.feature.adapter - -import androidx.recyclerview.widget.RecyclerView -import coil.load -import ru.otus.marketsample.databinding.ItemPromoBinding -import ru.otus.marketsample.promo.feature.PromoState - -class PromoHolder( - private val binding: ItemPromoBinding, -) : RecyclerView.ViewHolder(binding.root) { - - fun bind(promoState: PromoState) { - binding.image.load(promoState.image) - binding.name.text = promoState.name - binding.description.text = promoState.description - } -} diff --git a/app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt b/app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt deleted file mode 100644 index b1ad582..0000000 --- a/app/src/main/java/ru/otus/marketsample/promo/feature/di/PromoComponent.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ru.otus.marketsample.promo.feature.di - -import dagger.Component -import ru.otus.common.data.promo.PromoRepository -import ru.otus.common.di.FeatureScope -import ru.otus.marketsample.promo.feature.PromoListFragment - -@FeatureScope -@Component(dependencies = [PromoComponentDependencies::class]) -interface PromoComponent { - - @Component.Factory - interface Factory { - fun create(dependencies: PromoComponentDependencies): PromoComponent - } - - fun inject(productFragment: PromoListFragment) -} - -interface PromoComponentDependencies { - fun getPromoRepository(): PromoRepository -} diff --git a/app/src/main/java/ru/otus/marketsample/ui/UiText.kt b/app/src/main/java/ru/otus/marketsample/ui/UiText.kt new file mode 100644 index 0000000..02d7343 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/ui/UiText.kt @@ -0,0 +1,30 @@ +package ru.otus.marketsample.ui + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** + * Класс-обертка для работы со строками в UI. + * Позволяет передавать либо готовую строку, либо ID ресурса без привязки к Context во ViewModel. + */ +sealed class UiText { + data class DynamicString(val value: String) : UiText() + class StringResource(@param:StringRes val resId: Int, vararg val args: Any) : UiText() + + @Composable + fun asString(): String { + return when (this) { + is DynamicString -> value + is StringResource -> stringResource(resId, *args) + } + } + + fun asString(context: Context): String { + return when (this) { + is DynamicString -> value + is StringResource -> context.getString(resId, *args) + } + } +} diff --git a/app/src/main/java/ru/otus/marketsample/ui/navigation/MainBottomBar.kt b/app/src/main/java/ru/otus/marketsample/ui/navigation/MainBottomBar.kt new file mode 100644 index 0000000..ea0d01a --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/ui/navigation/MainBottomBar.kt @@ -0,0 +1,101 @@ +package ru.otus.marketsample.ui.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Icon +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.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import ru.otus.marketsample.ui.theme.MarketSampleTheme + +/** + * Вспомогательный компонент для отображения структуры главного экрана + * + * @param navController Контроллер навигации + * @param items Список элементов нижнего меню + * @param content Содержимое экрана + */ +@Composable +fun MainScreenContent( + navController: NavController, + items: ImmutableList, + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + modifier = modifier, + bottomBar = { MainBottomBar(navController, items, modifier = Modifier) } + ) { innerPadding -> + content(innerPadding) + } +} + +/** + * Нижняя панель навигации приложения + * + * @param navController Контроллер навигации + * @param items Список экранов для отображения в BottomBar + */ +@Composable +fun MainBottomBar( + navController: NavController, + items: ImmutableList, + modifier: Modifier = Modifier +) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + // Показываем BottomBar только на главных экранах + if (items.any { it.route == currentDestination?.route }) { + NavigationBar(modifier = modifier) { + items.forEach { screen -> + NavigationBarItem( + icon = { Icon(screen.icon, contentDescription = null) }, + label = { Text(stringResource(screen.resourceId)) }, + 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 + } + } + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun MainBottomBarPreview() { + MarketSampleTheme { + val items = persistentListOf(Screen.Products, Screen.Promo) + + // Для визуализации в превью отрисовываем NavigationBar напрямую, + // так как NavController в превью не имеет текущего маршрута по умолчанию. + NavigationBar { + items.forEach { screen -> + NavigationBarItem( + icon = { Icon(screen.icon, contentDescription = null) }, + label = { Text(stringResource(screen.resourceId)) }, + selected = screen == Screen.Products, + onClick = {} + ) + } + } + } +} diff --git a/app/src/main/java/ru/otus/marketsample/ui/navigation/Navigation.kt b/app/src/main/java/ru/otus/marketsample/ui/navigation/Navigation.kt new file mode 100644 index 0000000..844fb6f --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/ui/navigation/Navigation.kt @@ -0,0 +1,94 @@ +package ru.otus.marketsample.ui.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import kotlinx.collections.immutable.persistentListOf +import ru.otus.marketsample.R +import ru.otus.marketsample.details.feature.DetailsScreen +import ru.otus.marketsample.products.feature.ProductListScreen +import ru.otus.marketsample.promo.feature.PromoListScreen +import ru.otus.marketsample.ui.theme.MarketSampleTheme + +/** + * Главный экран приложения с навигацией и BottomBar. + * Инъекция ViewModel происходит автоматически через Hilt. + */ +@Composable +fun MainScreen( + modifier: Modifier = Modifier +) { + val navController = rememberNavController() + val items = persistentListOf(Screen.Products, Screen.Promo) + + MainScreenContent( + navController = navController, + items = items, + modifier = modifier + ) { innerPadding -> + NavHost( + navController, + startDestination = Screen.Products.route + ) { + composable(Screen.Products.route) { + ProductListScreen( + onProductClick = { productId -> + navController.navigate(Screen.Details.createRoute(productId)) + }, + modifier = Modifier.padding(innerPadding) + ) + } + composable(Screen.Promo.route) { + PromoListScreen( + modifier = Modifier.padding(innerPadding) + ) + } + composable( + Screen.Details.route, + arguments = listOf(navArgument("productId") { type = NavType.StringType }) + ) { + DetailsScreen( + modifier = Modifier.padding(innerPadding) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun MainScreenPreview() { + val navController = rememberNavController() + MarketSampleTheme { + MainScreenContent( + navController = navController, + items = persistentListOf(Screen.Products, Screen.Promo) + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.Products.route, + modifier = Modifier.padding(innerPadding) + ) { + composable(Screen.Products.route) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = stringResource(R.string.preview_products_screen)) + } + } + } + } + } +} diff --git a/app/src/main/java/ru/otus/marketsample/ui/navigation/Screen.kt b/app/src/main/java/ru/otus/marketsample/ui/navigation/Screen.kt new file mode 100644 index 0000000..72f6192 --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/ui/navigation/Screen.kt @@ -0,0 +1,30 @@ +package ru.otus.marketsample.ui.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Star +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.vector.ImageVector +import ru.otus.marketsample.R + +/** + * Класс, описывающий экраны приложения для навигации + * + * @property route Маршрут для NavHost + * @property resourceId ID ресурса строки для заголовка + * @property icon Иконка экрана + */ +@Immutable +sealed class Screen(val route: String, val resourceId: Int, val icon: ImageVector) { + /** Экран списка продуктов */ + object Products : Screen("products", R.string.title_products, Icons.AutoMirrored.Filled.List) + + /** Экран промо-акций */ + object Promo : Screen("promo", R.string.title_promo, Icons.Default.Star) + + /** Экран деталей продукта */ + object Details : + Screen("details/{productId}", R.string.title_details, Icons.AutoMirrored.Filled.List) { + fun createRoute(productId: String) = "details/$productId" + } +} diff --git a/app/src/main/java/ru/otus/marketsample/ui/theme/Theme.kt b/app/src/main/java/ru/otus/marketsample/ui/theme/Theme.kt new file mode 100644 index 0000000..fc2eebb --- /dev/null +++ b/app/src/main/java/ru/otus/marketsample/ui/theme/Theme.kt @@ -0,0 +1,23 @@ +package ru.otus.marketsample.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Тема приложения MarketSample + * + * @param modifier Модификатор + * @param content Содержимое, к которому будет применена тема + */ +@Composable +fun MarketSampleTheme( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = MaterialTheme.colorScheme, + typography = MaterialTheme.typography, + content = content + ) +} 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 @@ - - - - - - - - - - - - - -