From 187437b541e0f1ed21597528e4acacd4554908f8 Mon Sep 17 00:00:00 2001 From: ibolonkin Date: Fri, 17 Oct 2025 17:34:42 +0500 Subject: [PATCH 1/2] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BC=D0=B5=D0=B6=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 6 +++ .../java/com/example/practice3/Application.kt | 3 +- .../practice3/di/FilmsFeatureModule.kt | 12 +++++ .../com/example/practice3/di/MainModule.kt | 2 - .../presentation/model/FilmsListViewState.kt | 11 +++++ .../viewModel/FilmsListViewModel.kt | 32 ++++++++++++ .../com/example/practice3/uikit/ErrorItem.kt | 49 +++++++++++++++++++ .../practice3/uikit/FullscreenError.kt | 26 ++++++++++ .../practice3/uikit/FullscreenLoading.kt | 17 +++++++ .../com/example/practice3/uikit/Spacing.kt | 9 ++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 4 ++ 12 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/example/practice3/di/FilmsFeatureModule.kt create mode 100644 app/src/main/java/com/example/practice3/news/presentation/model/FilmsListViewState.kt create mode 100644 app/src/main/java/com/example/practice3/news/presentation/viewModel/FilmsListViewModel.kt create mode 100644 app/src/main/java/com/example/practice3/uikit/ErrorItem.kt create mode 100644 app/src/main/java/com/example/practice3/uikit/FullscreenError.kt create mode 100644 app/src/main/java/com/example/practice3/uikit/FullscreenLoading.kt create mode 100644 app/src/main/java/com/example/practice3/uikit/Spacing.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21ab5ca..a708abb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.jetbrains.kotlin.serialization) } android { @@ -68,4 +69,9 @@ dependencies { // DI implementation(libs.bundles.koin) + + // network + implementation(libs.retrofit) + implementation(libs.retrofit.serialization) + implementation(libs.kotlinx.serialization.json) } \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/Application.kt b/app/src/main/java/com/example/practice3/Application.kt index fc0c4f4..5959da7 100644 --- a/app/src/main/java/com/example/practice3/Application.kt +++ b/app/src/main/java/com/example/practice3/Application.kt @@ -1,6 +1,7 @@ package com.example.practice3 import android.app.Application +import com.example.practice3.di.filmsFeatureModule import com.example.practice3.di.mainModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger @@ -12,7 +13,7 @@ class Application: Application(){ startKoin{ androidLogger() androidContext(this@Application) - modules(mainModule) + modules(mainModule, filmsFeatureModule) } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/di/FilmsFeatureModule.kt b/app/src/main/java/com/example/practice3/di/FilmsFeatureModule.kt new file mode 100644 index 0000000..207535c --- /dev/null +++ b/app/src/main/java/com/example/practice3/di/FilmsFeatureModule.kt @@ -0,0 +1,12 @@ +package com.example.practice3.di + +import com.example.practice3.Films +import com.example.practice3.navigation.Route +import com.example.practice3.navigation.TopLevelBackStack +import com.example.practice3.news.presentation.viewModel.FilmsDetailsViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val filmsFeatureModule = module { + viewModel { FilmsDetailsViewModel(get(), get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/di/MainModule.kt b/app/src/main/java/com/example/practice3/di/MainModule.kt index 613dae7..b36d249 100644 --- a/app/src/main/java/com/example/practice3/di/MainModule.kt +++ b/app/src/main/java/com/example/practice3/di/MainModule.kt @@ -11,6 +11,4 @@ import org.koin.core.module.dsl.viewModelOf val mainModule = module { single { TopLevelBackStack(Films) } - //viewModelOf(::FilmsDetailsViewModel) - viewModel { FilmsDetailsViewModel(get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/presentation/model/FilmsListViewState.kt b/app/src/main/java/com/example/practice3/news/presentation/model/FilmsListViewState.kt new file mode 100644 index 0000000..c45f2e6 --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/presentation/model/FilmsListViewState.kt @@ -0,0 +1,11 @@ +package com.example.practice3.news.presentation.model + +data class FilmsListViewState( + val state: State = State.Loading, +) { + sealed interface State { + object Loading : State + data class Error(val error: String): State + data class Success(val data: List) : State + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/presentation/viewModel/FilmsListViewModel.kt b/app/src/main/java/com/example/practice3/news/presentation/viewModel/FilmsListViewModel.kt new file mode 100644 index 0000000..1f6d847 --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/presentation/viewModel/FilmsListViewModel.kt @@ -0,0 +1,32 @@ +package com.example.practice3.news.presentation.viewModel + +import androidx.lifecycle.ViewModel +import com.example.practice3.FilmsDetails +import com.example.practice3.navigation.Route +import com.example.practice3.navigation.TopLevelBackStack +import com.example.practice3.news.presentation.MockData +import com.example.practice3.news.presentation.model.FilmsListViewState +import com.example.practice3.news.presentation.model.FilmsUiModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class FilmsListViewModel( + private val topLevelBackStack: TopLevelBackStack, + +): ViewModel() { + private val mutableState = MutableStateFlow(FilmsListViewState()) + val viewState = mutableState.asStateFlow() + + init { + mutableState.update { + it.copy( + state = FilmsListViewState.State.Success(MockData.getFilms()) + ) + } + } + + fun onFilmsClick(films: FilmsUiModel) { + topLevelBackStack.add(FilmsDetails(films)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/uikit/ErrorItem.kt b/app/src/main/java/com/example/practice3/uikit/ErrorItem.kt new file mode 100644 index 0000000..8371878 --- /dev/null +++ b/app/src/main/java/com/example/practice3/uikit/ErrorItem.kt @@ -0,0 +1,49 @@ +package com.example.practice3.uikit + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.example.practice3.ui.theme.Practice3Theme + +@Composable +fun ErrorItem( + modifier: Modifier = Modifier, + error: String? = null, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clickable{ onClick() } + .padding(Spacing.small), + horizontalAlignment = Alignment.CenterHorizontally, + ){ + Text( + text = error ?: "Произошла ошибка", + style = MaterialTheme.typography.bodyMedium + ) + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ErrorItemPreview() { + Practice3Theme{ + ErrorItem {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/uikit/FullscreenError.kt b/app/src/main/java/com/example/practice3/uikit/FullscreenError.kt new file mode 100644 index 0000000..994ed3b --- /dev/null +++ b/app/src/main/java/com/example/practice3/uikit/FullscreenError.kt @@ -0,0 +1,26 @@ +package com.example.practice3.uikit + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun FullscreenError( + retry: () -> Unit, + text: String? = null, +) { + Box( + Modifier + .fillMaxSize() + .padding(Spacing.medium), + contentAlignment = Alignment.Center + ) { + ErrorItem( + error = text, + onClick = retry, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/uikit/FullscreenLoading.kt b/app/src/main/java/com/example/practice3/uikit/FullscreenLoading.kt new file mode 100644 index 0000000..fb52d50 --- /dev/null +++ b/app/src/main/java/com/example/practice3/uikit/FullscreenLoading.kt @@ -0,0 +1,17 @@ +package com.example.practice3.uikit + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment + +@Composable +fun FullscreenLoading() { + Box( + Modifier.fillMaxSize(), contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/uikit/Spacing.kt b/app/src/main/java/com/example/practice3/uikit/Spacing.kt new file mode 100644 index 0000000..ef1e3cb --- /dev/null +++ b/app/src/main/java/com/example/practice3/uikit/Spacing.kt @@ -0,0 +1,9 @@ +package com.example.practice3.uikit + +import androidx.compose.ui.unit.dp + +object Spacing { + val mini get() = 4.dp + val small get() = 8.dp + val medium get() = 16.dp +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..327b22f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.jetbrains.kotlin.serialization) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c769a5d..7792551 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ composeBom = "2025.09.01" glide-compose = "1.0.0-beta08" glide = "5.0.5" koinAndroid = "4.1.0" +retrofit = "3.0.0" materialIconsCore = "1.7.3" nav3Core = "1.0.0-alpha10" @@ -46,11 +47,14 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationCore" } material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "materialIconsCore" } +retrofit = {module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit"} +retrofit-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +jetbrains-kotlin-serialization = {id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"} [bundles] navigation3 = [ From 98f947204c3d31f3b3230048f0ce38859a20e4bd Mon Sep 17 00:00:00 2001 From: ibolonkin Date: Sat, 18 Oct 2025 19:23:26 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D0=92=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D0=BA=D1=83=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 ++ app/src/main/AndroidManifest.xml | 1 + .../java/com/example/practice3/Application.kt | 3 +- .../java/com/example/practice3/MainScreen.kt | 2 +- .../example/practice3/core/CoroutineUtils.kt | 44 ++++++++++++++++ .../practice3/di/FilmsFeatureModule.kt | 14 +++++ .../com/example/practice3/di/NetworkModule.kt | 34 ++++++++++++ .../mapper/FilmsResponseToEntityMapper.kt | 18 +++++++ .../practice3/news/data/model/FilmsApi.kt | 15 ++++++ .../news/data/model/FilmsListResponse.kt | 20 +++++++ .../news/data/model/FireStoreModel.kt | 16 ++++++ .../news/data/repository/FilmsRepository.kt | 17 ++++++ .../news/domain/interactor/FilmsInteractor.kt | 9 ++++ .../news/domain/model/FilmsEntity.kt | 9 ++++ .../practice3/news/presentation/MockData.kt | 20 +++---- .../news/presentation/model/FilmsUiModel.kt | 2 +- .../presentation/screen/FilmsListScreen.kt | 52 +++++++++++++++---- .../viewModel/FilmsListViewModel.kt | 39 +++++++++++++- .../main/res/xml/network_security_config.xml | 11 ++++ 19 files changed, 305 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/example/practice3/core/CoroutineUtils.kt create mode 100644 app/src/main/java/com/example/practice3/di/NetworkModule.kt create mode 100644 app/src/main/java/com/example/practice3/news/data/mapper/FilmsResponseToEntityMapper.kt create mode 100644 app/src/main/java/com/example/practice3/news/data/model/FilmsApi.kt create mode 100644 app/src/main/java/com/example/practice3/news/data/model/FilmsListResponse.kt create mode 100644 app/src/main/java/com/example/practice3/news/data/model/FireStoreModel.kt create mode 100644 app/src/main/java/com/example/practice3/news/data/repository/FilmsRepository.kt create mode 100644 app/src/main/java/com/example/practice3/news/domain/interactor/FilmsInteractor.kt create mode 100644 app/src/main/java/com/example/practice3/news/domain/model/FilmsEntity.kt create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a708abb..e7eedb1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,4 +74,7 @@ dependencies { implementation(libs.retrofit) implementation(libs.retrofit.serialization) implementation(libs.kotlinx.serialization.json) + debugImplementation("com.github.chuckerteam.chucker:library:4.0.0") + releaseImplementation("com.github.chuckerteam.chucker:library-no-op:4.0.0") + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d90c900..85115f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/Theme.Practice3"> { - FilmsListScreen(topLevelBackStack) + FilmsListScreen() } entry( metadata = DialogSceneStrategy.dialog(DialogProperties()) diff --git a/app/src/main/java/com/example/practice3/core/CoroutineUtils.kt b/app/src/main/java/com/example/practice3/core/CoroutineUtils.kt new file mode 100644 index 0000000..dc3f920 --- /dev/null +++ b/app/src/main/java/com/example/practice3/core/CoroutineUtils.kt @@ -0,0 +1,44 @@ +package com.example.practice3.core + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +fun CoroutineScope.launchLoadingAndError( + handleError: (Throwable) -> Unit = {}, + updateLoading: (Boolean) -> Unit = {}, + block: suspend CoroutineScope.() -> Unit, +): Job { + val context = + CoroutineExceptionHandler { _, throwable -> handleError.invoke(throwable) } + + LoadingContextHandler(updateLoading) + + return launch(context) { + handleLoading(this, block) + } +} + +class LoadingContextHandler( + private val updateLoading: (Boolean) -> Unit +): CoroutineContext.Element { + override val key: CoroutineContext.Key<*> = Key + + companion object Key: CoroutineContext.Key + + fun showProgress() = updateLoading.invoke(true) + fun hideProgress() = updateLoading.invoke(false) +} + +private suspend fun handleLoading( + coroutineScope: CoroutineScope, + block: suspend CoroutineScope.() -> T +): T { + return coroutineScope.runCatching { + coroutineScope.coroutineContext[LoadingContextHandler]?.showProgress() + block() + }. also { + coroutineScope.coroutineContext[LoadingContextHandler]?.hideProgress() + }.getOrThrow() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/di/FilmsFeatureModule.kt b/app/src/main/java/com/example/practice3/di/FilmsFeatureModule.kt index 207535c..bef8ea9 100644 --- a/app/src/main/java/com/example/practice3/di/FilmsFeatureModule.kt +++ b/app/src/main/java/com/example/practice3/di/FilmsFeatureModule.kt @@ -3,10 +3,24 @@ package com.example.practice3.di import com.example.practice3.Films import com.example.practice3.navigation.Route import com.example.practice3.navigation.TopLevelBackStack +import com.example.practice3.news.data.mapper.FilmsResponseToEntityMapper +import com.example.practice3.news.data.model.FilmsApi +import com.example.practice3.news.data.repository.FilmsRepository +import com.example.practice3.news.domain.interactor.FilmsInteractor import com.example.practice3.news.presentation.viewModel.FilmsDetailsViewModel +import com.example.practice3.news.presentation.viewModel.FilmsListViewModel import org.koin.core.module.dsl.viewModel import org.koin.dsl.module +import retrofit2.Retrofit val filmsFeatureModule = module { viewModel { FilmsDetailsViewModel(get(), get()) } + viewModel { FilmsListViewModel(get(), get()) } + + single { get().create(FilmsApi::class.java) } + + factory { FilmsResponseToEntityMapper() } + single { FilmsRepository(get(), get()) } + + single { FilmsInteractor(get()) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/di/NetworkModule.kt b/app/src/main/java/com/example/practice3/di/NetworkModule.kt new file mode 100644 index 0000000..2f3537f --- /dev/null +++ b/app/src/main/java/com/example/practice3/di/NetworkModule.kt @@ -0,0 +1,34 @@ +package com.example.practice3.di + +import com.chuckerteam.chucker.api.ChuckerInterceptor +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +val networkModule = module { + single { + OkHttpClient.Builder() + .addInterceptor(ChuckerInterceptor(androidContext())) + .build() + } + + single { + val json = Json { + explicitNulls = false + ignoreUnknownKeys = true + } + Retrofit.Builder() + .baseUrl("http://26.15.99.17:8001/") + .addConverterFactory( + json.asConverterFactory( + "application/json; charset=UTF8".toMediaType() + ) + ) + .client(get()) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/data/mapper/FilmsResponseToEntityMapper.kt b/app/src/main/java/com/example/practice3/news/data/mapper/FilmsResponseToEntityMapper.kt new file mode 100644 index 0000000..2091856 --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/data/mapper/FilmsResponseToEntityMapper.kt @@ -0,0 +1,18 @@ +package com.example.practice3.news.data.mapper + +import com.example.practice3.news.data.model.FilmsListResponse +import com.example.practice3.news.domain.model.FilmsEntity + +class FilmsResponseToEntityMapper { + fun mapResponse(response: FilmsListResponse): List { + return response.documents?.map { doc -> + FilmsEntity( + id = doc.id.orEmpty(), + title = doc.title.orEmpty(), + descr = doc.descr, + year = doc.year.orEmpty(), + imageUrl = doc.imageUrl, + ) + }.orEmpty() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/data/model/FilmsApi.kt b/app/src/main/java/com/example/practice3/news/data/model/FilmsApi.kt new file mode 100644 index 0000000..bef590c --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/data/model/FilmsApi.kt @@ -0,0 +1,15 @@ +package com.example.practice3.news.data.model + +import retrofit2.http.GET +import retrofit2.http.Query + +interface FilmsApi { + @GET("/") + suspend fun getFilms( +// @Query("orderBy") orderBy: String = CREATE_TIME_KEY, + ): FilmsListResponse + +// companion object { +// private const val CREATE_TIME_KEY = "createTime desc" +// } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/data/model/FilmsListResponse.kt b/app/src/main/java/com/example/practice3/news/data/model/FilmsListResponse.kt new file mode 100644 index 0000000..2443872 --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/data/model/FilmsListResponse.kt @@ -0,0 +1,20 @@ +package com.example.practice3.news.data.model + +import androidx.annotation.Keep +import kotlinx.serialization.Serializable + +@Keep +@Serializable +class FilmsListResponse( + val documents: List?, +) + +@Keep +@Serializable +class FilmsListDocument( + val id: String?, + val title: String?, + val descr: String?, + val year: String?, + val imageUrl: String?, +) \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/data/model/FireStoreModel.kt b/app/src/main/java/com/example/practice3/news/data/model/FireStoreModel.kt new file mode 100644 index 0000000..ca42add --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/data/model/FireStoreModel.kt @@ -0,0 +1,16 @@ +package com.example.practice3.news.data.model + +import androidx.annotation.Keep +import kotlinx.serialization.Serializable + +@Keep +@Serializable +class StringFireStoreModel(val stringValue: String?) + +@Keep +@Serializable +class BooleanFireStoreModel(val booleanValue: Boolean?) + +@Keep +@Serializable +class NumberFireStoreModel(val integerValue: Int?) diff --git a/app/src/main/java/com/example/practice3/news/data/repository/FilmsRepository.kt b/app/src/main/java/com/example/practice3/news/data/repository/FilmsRepository.kt new file mode 100644 index 0000000..cd1c39c --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/data/repository/FilmsRepository.kt @@ -0,0 +1,17 @@ +package com.example.practice3.news.data.repository + +import com.example.practice3.news.data.mapper.FilmsResponseToEntityMapper +import com.example.practice3.news.data.model.FilmsApi +import com.example.practice3.news.domain.model.FilmsEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class FilmsRepository( + private val api: FilmsApi, + private val mapper: FilmsResponseToEntityMapper, +) { + suspend fun getFilms(): List = withContext(Dispatchers.IO) { + val response = api.getFilms() + mapper.mapResponse(response) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/domain/interactor/FilmsInteractor.kt b/app/src/main/java/com/example/practice3/news/domain/interactor/FilmsInteractor.kt new file mode 100644 index 0000000..2758c36 --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/domain/interactor/FilmsInteractor.kt @@ -0,0 +1,9 @@ +package com.example.practice3.news.domain.interactor + +import com.example.practice3.news.data.repository.FilmsRepository + +class FilmsInteractor ( + private val filmsRepository: FilmsRepository, +) { + suspend fun getFilms() = filmsRepository.getFilms() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/domain/model/FilmsEntity.kt b/app/src/main/java/com/example/practice3/news/domain/model/FilmsEntity.kt new file mode 100644 index 0000000..bbc0e37 --- /dev/null +++ b/app/src/main/java/com/example/practice3/news/domain/model/FilmsEntity.kt @@ -0,0 +1,9 @@ +package com.example.practice3.news.domain.model + +class FilmsEntity ( + val id: String, + val title: String, + val descr: String?, + val year: String, + val imageUrl: String?, +) \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/presentation/MockData.kt b/app/src/main/java/com/example/practice3/news/presentation/MockData.kt index a79b675..521fe18 100644 --- a/app/src/main/java/com/example/practice3/news/presentation/MockData.kt +++ b/app/src/main/java/com/example/practice3/news/presentation/MockData.kt @@ -5,49 +5,49 @@ import com.example.practice3.news.presentation.model.FilmsUiModel object MockData { fun getFilms(): List = listOf( FilmsUiModel( - id = 1, + id = "1", title = "1+1", descr = "Аристократ на коляске нанимает в сиделки бывшего заключенного. Искрометная французская комедия с Омаром Си", year = "2011", imageUrl = "https://avatars.mds.yandex.net/get-ott/236744/2a00000198530fb3e592ad08b06f9b81d22b/300x450" ), FilmsUiModel( - id = 2, + id = "2", title = "Интерстеллар", descr = "Когда засуха, пыльные бури и вымирание растений приводят человечество к продовольственному кризису, коллектив исследователей и учёных отправляется сквозь червоточину (которая предположительно соединяет области пространства-времени через большое расстояние) в путешествие, чтобы превзойти прежние ограничения для космических путешествий человека и найти планету с подходящими для человечества условиями.", year = "2014", imageUrl = "https://avatars.mds.yandex.net/get-kinopoisk-image/1600647/430042eb-ee69-4818-aed0-a312400a26bf/300x450" ), FilmsUiModel( - id = 3, + id = "3", title = "Побег из Шоушенка", descr = "Бухгалтер Энди Дюфрейн обвинён в убийстве собственной жены и её любовника. Оказавшись в тюрьме под названием Шоушенк, он сталкивается с жестокостью и беззаконием, царящими по обе стороны решётки. Каждый, кто попадает в эти стены, становится их рабом до конца жизни. Но Энди, обладающий живым умом и доброй душой, находит подход как к заключённым, так и к охранникам, добиваясь их особого к себе расположения.", year = "1994", imageUrl = "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/0b76b2a2-d1c7-4f04-a284-80ff7bb709a4/300x450" ), FilmsUiModel( - id = 4, + id = "4", title = "Джентльмены", descr = "Один ушлый американец ещё со студенческих лет приторговывал наркотиками, а теперь придумал схему нелегального обогащения с использованием поместий обедневшей английской аристократии и очень неплохо на этом разбогател. Другой пронырливый журналист приходит к Рэю, правой руке американца, и предлагает тому купить киносценарий, в котором подробно описаны преступления его босса при участии других представителей лондонского криминального мира — партнёра-еврея, китайской диаспоры, чернокожих спортсменов и даже русского олигарха.", year = "2019", imageUrl = "https://avatars.mds.yandex.net/get-ott/2385704/2a0000019854f2d5c8ce13461055033ab990/300x450" ), FilmsUiModel( - id = 5, + id = "5", title = "Зеленая миля", descr = "Пол Эджкомб — начальник блока смертников в тюрьме «Холодная гора», каждый из узников которого однажды проходит «зеленую милю» по пути к месту казни. Пол повидал много заключённых и надзирателей за время работы. Однако гигант Джон Коффи, обвинённый в страшном преступлении, стал одним из самых необычных обитателей блока.", year = "1999", imageUrl = "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/4057c4b8-8208-4a04-b169-26b0661453e3/300x450" ), FilmsUiModel( - id = 6, + id = "6", title = "Остров проклятых", descr = "Два американских судебных пристава отправляются на один из островов в штате Массачусетс, чтобы расследовать исчезновение пациентки клиники для умалишенных преступников. При проведении расследования им придется столкнуться с паутиной лжи, обрушившимся ураганом и смертельным бунтом обитателей клиники.", year = "2009", imageUrl = "https://avatars.mds.yandex.net/get-ott/224348/2a00000198528f853134273ea785844e1c8a/300x450" ), FilmsUiModel( - id = 7, + id = "7", title = "Форрест Гамп", descr = "Сидя на автобусной остановке, Форрест Гамп — не очень умный, но добрый и открытый парень — рассказывает случайным встречным историю своей необыкновенной жизни.\n" + "\n" + @@ -56,7 +56,7 @@ object MockData { imageUrl = "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/3560b757-9b95-45ec-af8c-623972370f9d/300x450" ), FilmsUiModel( - id = 8, + id = "8", title = "Декстер", descr = "Я — Декстер. Декстер Морган. Я работаю судмедэкспертом в полиции Майами. Я не понимаю любви, мне безразличен секс, и у меня нет чувств. А еще я серийный убийца.\n" + "\n" + @@ -67,7 +67,7 @@ object MockData { imageUrl = "https://kinopoisk-ru.clstorage.net/E1X7c0131/9f2a5d5Xm/pwMBaE92elLg8D5C2KuWnBgha5dJlnfxqoMU32ZQKy57F-JADL7SQsAMKqtmIhlmgTmSHUQzMGzio3BIhs_4z-dXCHUaf29ntYVON_3vz91ZAK9T2UPDEQIahH-SuUo7a3aTNWW_2nulAUbSbzOA-tScQ0gVRun4qgKw1Tv2wJbrby0o4yqHrvT476qYO85kVAizP01L8-Fu9sBDCj3ag6ZSi7lB41772WHngscnHCfY6JuAtT6xgMYmDMnHzjBGk2-N-I-ag0KcXHtKtD-OVDRQh5cV40-5-l6oXwrVDg_-clupSdu6gzUlg8r3szQ7hJybbKEK0ZTqvvwdu3oEMpOPKWSDblfyiOhj8mRnerDIeBPPbdMf_W5ykEuCLdp_Znp7OY1XuhdRFd9660vYDwzYx1y9qlmoJg6ctWOaHFIr8-EsZzYbLtA0I94cPwLkpCTn82H7Y91uCmDPfiGeq4qekwFtU6Z_tRW3fuNrWOdITHcQrcaVKP6aqDnbdhyOgw9FPIcS8ybkQO_uXKPisEwQsyfZp5_hgmocL75Vdq-eAn81UQf6ox2tL1bDWyCjEEj_ZJE63dBesiDxT_YkTnObxWR3YpOqUPSjsnynskzQdI8PYafXUZ7aLNPyDU4rMo6jBYUXBlflZTsmI09YA0RQZ_BVxkkYUi6YNTeWbHa_ewmsV5orKpx4I34s_yYAOJzzA0HT7wl28rh7PulWSwomM8WRP8ojWZlDgu_HyNO8LMNYIWJJaKKu_P23lrTi8xsVYOteM-aEtNdSiCOeICBk--sR1yMFlla891ZdrgPeKv-ZfcOy58FZ_8oje8hLzJxv-K3qMdhicqiBg6pkuhv7PTyXEhduvHTjrqBrEuRIAEe76RdbhaZGwHPiOaZznpo3zXWLis8dAUfaM8fM2wTU92idasUQRo6YaS8uEII_m7V4b26fNkAA1x5sN2bYoAxby_Fng60eRjwz9lmCs-oK67ntM66jnfF7jiPbSKfwTOPkFUolKCIecOHruriykw_FKJ8uswI8dH_eVOc2yJxolzPtf_elGm6411612gfypptRhau-yzklj3ZrP-ivyPgfCL06QfzGqsDJX7asEguLjSSjfieiWLwbPih_tiyIoBf_ZVuPsTayuHeyQQK7qkYPNWlLCkuFBb-C42-0T8zsv2wVItH8InJYie8myJKXn91884qHclzQJ35YT7JMKIDDhwVXn7F-cvh7skGeR1bKq4kJS7Z7XfXDjoP_MLNccBeEQS4ZbMqCXGW3opDep8spgOsa84KU6CNOICMinAhwyycV05O9nsaA83Zlgl-a2v95NcfKmyUFL1IXo3Tb-GAb1JkKdQTmisgxJ3ZM8nvrAXyTDhu66PB_bqQn5rCYxJfrPWNXieb6PItWoaqjfkYfXWnjonOFSXfK79s454AkD9y53h2U1t5c-e9eJA4b66kUCybf-gBob874uzbcbCBbixV3T-kyMnznPmXeN_ZGh1UZEy4HCdVnih_bNNfIPDcIPWrBmDIycInrihhysz9BPA8Oqwr8NBdCmHc6qKj0829hi4-JDsZsf6bZNkue8o-BYUdym00dq643j3gfhFyD2Gku3ejCorRxV6I0dmMPmdx3Do8ugGyn1iT3lphY8JO_tdfv0S5-GH-6DdbjGmJXXVHbjhuNefdmL6s8WzTkU-hBUuWwsiakDQOqYArzw8E0w3Y70hgw91JYI-roiCDHG3FPAzUq2qw77umaM27al509oyaviQXTyhMDcN_wKMNQ6TJxLCrqyBE_PnRSY5dlNH-uP0KMZNPW-A92GABgxxdR8yv5dmoot3Y5utMOfq_FhZPe37X5e97jT5zffFz7-C1S0bQSYijFS1LMCr9_sRRDhmcO-Ei_tgyjbsikgNczxQdvNRaGaKPmPa4DfkLrXdHryp9FkeeuLwOgGxise3wdQmk89tKM8fv-FG4Hn9m8l6IPXmSsK9Z8_340RPRn-0nPa3V-ciz_BqnOO9LCI6Vhu5aXPd1HVov7wJPsyFsgzU616OYWpEVjmgzW53s5YKs2F05IoK8q4BdyxFggu0uN31M5YgIAtwZBXleiWqM1WQu-m11xv17za0xb0GRfULk-oeRGmpiZt1awkm9rpTT3tuu-FPz_cvyrgrQ00BtjjcPfjTa69K-mScJrauYn2UVTTg-NaaPS9wfE2zwcS9QReuXg2gp8_de2nMJj-40AH4qv1kBE1xKAJzIAyFT7_1EHz6mKAjzrdk0Gawby86GRV97v3f3DyiNzGOeYQPvgQVpZYLISHNl3prB-SxeNpH9-u_60SH_GhMceAKzcdyP9n2ulEsLI855Rslu6DqsF6ZOepxF5f37HS9xTYMwL9CEu2SA6DihJL064cr9vyQxbijMyXGjLTpAP-kgY6IeD5csLPV6y6K-GzTY3cpKbKfGjWhNpUdM252-Um5g8y6DRwp0IuuaQzffWDH6Xb5WQe3orrpyEG0psZxKIzPyPx7mnW1XyagCrjlFOp2Ye-w1pnzLbJS0D2uvP8Md4zD-MJT41lP7-dLGHgrzKLxM1iGcuP-ZwyF9GmBNiAMgEQ0dhT58x1uqEuwKtBtui2oupTccyUz3Jg15_26i75LxDELmisezS6oyZ00rwXu9HvQjjJrsilPCbJqz7RthskFefWY8LdQoCbPceOfbLrm6PnQ3Xyk9dZb827z_Ao0iwYyyl6hUMKg50kUuOoJpz5yEYqxYPgpR0p078f_YAhOCfb0lP49m6EjA79sHG86ra8wH5awIThWkz1v_HaFewRMt46TLZfFoudN2Ppize6w-BcBP-j6I0-FvWjP82FDx8F0_NT3vhbn4kW2pNpnfSGg958b-SJ1kJwwI3_6gPTCxH6GFa8ZjyBjDZIwrovmdrHZB3fhPinFhHSuxrjkCw7GMbbWv3UY6yiK-6Ud5foiYfDcErRsORyW92u_-oOwjoPxQhhiGYtiaQ", ), FilmsUiModel( - id = 9, + id = "9", title = "Бойцовский клуб", descr = "Сотрудник страховой компании страдает хронической бессонницей и отчаянно пытается вырваться из мучительно скучной жизни. Однажды в очередной командировке он встречает некоего Тайлера Дёрдена — харизматического торговца мылом с извращенной философией. Тайлер уверен, что самосовершенствование — удел слабых, а единственное, ради чего стоит жить, — саморазрушение.\n" + "\n" + @@ -76,7 +76,7 @@ object MockData { imageUrl = "https://avatars.mds.yandex.net/get-kinopoisk-image/4716873/85b585ea-410f-4d1c-aaa5-8d242756c2a4/300x450" ), FilmsUiModel( - id = 10, + id = "10", title = "Брат", descr = "Демобилизовавшись, Данила Багров возвращается в родной городок. Но скучная жизнь провинции его не устраивает, и он отправляется в Петербург, где, по слухам, уже несколько лет процветает его старший брат. Данила находит брата, но всё оказывается не так просто — брат работает наемным убийцей.", year = "1997", diff --git a/app/src/main/java/com/example/practice3/news/presentation/model/FilmsUiModel.kt b/app/src/main/java/com/example/practice3/news/presentation/model/FilmsUiModel.kt index 60d4d48..e03f9d3 100644 --- a/app/src/main/java/com/example/practice3/news/presentation/model/FilmsUiModel.kt +++ b/app/src/main/java/com/example/practice3/news/presentation/model/FilmsUiModel.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class FilmsUiModel ( - val id: Int, + val id: String?, val title: String, val descr: String?, val year: String, diff --git a/app/src/main/java/com/example/practice3/news/presentation/screen/FilmsListScreen.kt b/app/src/main/java/com/example/practice3/news/presentation/screen/FilmsListScreen.kt index 91664c7..96f2acc 100644 --- a/app/src/main/java/com/example/practice3/news/presentation/screen/FilmsListScreen.kt +++ b/app/src/main/java/com/example/practice3/news/presentation/screen/FilmsListScreen.kt @@ -10,28 +10,62 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.practice3.Films import com.example.practice3.FilmsDetails import com.example.practice3.navigation.Route import com.example.practice3.navigation.TopLevelBackStack import com.example.practice3.news.presentation.MockData +import com.example.practice3.news.presentation.model.FilmsListViewState import com.example.practice3.news.presentation.model.FilmsUiModel +import com.example.practice3.news.presentation.viewModel.FilmsListViewModel +import com.example.practice3.uikit.FullscreenError +import com.example.practice3.uikit.FullscreenLoading +import org.koin.androidx.compose.koinViewModel @Composable -fun FilmsListScreen(topLevelBackStack: TopLevelBackStack) { - val films = remember { MockData.getFilms() } +fun FilmsListScreen() { + val viewModel = koinViewModel() + val state by viewModel.viewState.collectAsStateWithLifecycle() - LazyColumn( - modifier = Modifier.padding(top = 12.dp) - ) { - films.forEach { films -> - item(key = films.id) { - FilmsListItem(films, {topLevelBackStack.add(FilmsDetails(it))}) + FilmsListScreenContent( + state.state, + viewModel::onFilmsClick, + viewModel::onRetryClick, + ) +} + +@Composable +private fun FilmsListScreenContent( + state: FilmsListViewState.State, + onFilmsClick: (FilmsUiModel) -> Unit = {}, + onRetryClick: () -> Unit = {}, +) { + when(state) { + FilmsListViewState.State.Loading -> { + FullscreenLoading() + } + + is FilmsListViewState.State.Error -> { + FullscreenError( + retry = {onRetryClick()}, + text = state.error + ) + } + + is FilmsListViewState.State.Success -> { + LazyColumn { + state.data.forEach { films -> + item { + FilmsListItem(films) { onFilmsClick(it) } + } + } } } } @@ -74,5 +108,5 @@ fun FilmsListItem(films: FilmsUiModel, onFilmsClick: (FilmsUiModel) -> Unit) { @Preview(showBackground = true) @Composable fun FilmsListPreview() { - FilmsListScreen(TopLevelBackStack(Films)) + FilmsListScreen() } \ No newline at end of file diff --git a/app/src/main/java/com/example/practice3/news/presentation/viewModel/FilmsListViewModel.kt b/app/src/main/java/com/example/practice3/news/presentation/viewModel/FilmsListViewModel.kt index 1f6d847..86ffcb7 100644 --- a/app/src/main/java/com/example/practice3/news/presentation/viewModel/FilmsListViewModel.kt +++ b/app/src/main/java/com/example/practice3/news/presentation/viewModel/FilmsListViewModel.kt @@ -1,19 +1,26 @@ package com.example.practice3.news.presentation.viewModel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import com.example.practice3.FilmsDetails +import com.example.practice3.core.launchLoadingAndError import com.example.practice3.navigation.Route import com.example.practice3.navigation.TopLevelBackStack +import com.example.practice3.news.domain.interactor.FilmsInteractor +import com.example.practice3.news.domain.model.FilmsEntity import com.example.practice3.news.presentation.MockData import com.example.practice3.news.presentation.model.FilmsListViewState import com.example.practice3.news.presentation.model.FilmsUiModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.String class FilmsListViewModel( private val topLevelBackStack: TopLevelBackStack, - + private val interactor: FilmsInteractor ): ViewModel() { private val mutableState = MutableStateFlow(FilmsListViewState()) val viewState = mutableState.asStateFlow() @@ -24,9 +31,37 @@ class FilmsListViewModel( state = FilmsListViewState.State.Success(MockData.getFilms()) ) } + loadFilms() } fun onFilmsClick(films: FilmsUiModel) { topLevelBackStack.add(FilmsDetails(films)) } -} \ No newline at end of file + + fun onRetryClick() { + loadFilms() + } + + private fun loadFilms() { + viewModelScope.launchLoadingAndError( + handleError = { e -> updateState(FilmsListViewState.State.Error(e.localizedMessage.orEmpty())) } + ) { + updateState(FilmsListViewState.State.Loading) + val films = interactor.getFilms() + updateState(FilmsListViewState.State.Success(mapToUi(films))) + } + } + + private fun updateState(state: FilmsListViewState.State) = + mutableState.update { it.copy(state = state) } + + private fun mapToUi(films: List): List = films.map { films -> + FilmsUiModel( + id = films.id, + title = films.title, + descr = films.descr, + year = films.year, + imageUrl = films.imageUrl, + ) + } +} diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..566a6a1 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + + 26.15.99.17 + + + + +