├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable
│ │ │ │ ├── play_button.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── khamroev
│ │ │ │ └── movie
│ │ │ │ ├── domain
│ │ │ │ ├── model
│ │ │ │ │ └── Movie.kt
│ │ │ │ ├── repository
│ │ │ │ │ └── MovieRepository.kt
│ │ │ │ └── usecase
│ │ │ │ │ ├── GetMovieUseCase.kt
│ │ │ │ │ └── GetMoviesUseCase.kt
│ │ │ │ ├── data
│ │ │ │ ├── remote
│ │ │ │ │ ├── MoviesResponse.kt
│ │ │ │ │ ├── MovieRemote.kt
│ │ │ │ │ ├── MovieService.kt
│ │ │ │ │ ├── RemoteDataSource.kt
│ │ │ │ │ └── KtorApi.kt
│ │ │ │ ├── util
│ │ │ │ │ └── Mappers.kt
│ │ │ │ └── repository
│ │ │ │ │ └── MovieRepositoryImpl.kt
│ │ │ │ ├── util
│ │ │ │ └── Dispatcher.kt
│ │ │ │ ├── di
│ │ │ │ ├── AppModule.kt
│ │ │ │ └── SharedModules.kt
│ │ │ │ ├── MovieConfig.kt
│ │ │ │ ├── common
│ │ │ │ ├── Destinations.kt
│ │ │ │ └── MovieAppBar.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── detail
│ │ │ │ ├── DetailViewModel.kt
│ │ │ │ └── DetailScreen.kt
│ │ │ │ ├── MovieTheme.kt
│ │ │ │ ├── home
│ │ │ │ ├── HomeViewModel.kt
│ │ │ │ ├── MovieListItem.kt
│ │ │ │ └── HomeScreen.kt
│ │ │ │ ├── pullrefresh
│ │ │ │ ├── PullRefreshIndicatorTransform.kt
│ │ │ │ ├── PullRefresh.kt
│ │ │ │ ├── PullRefreshIndicator.kt
│ │ │ │ └── PullRefreshState.kt
│ │ │ │ └── MovieApp.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── khamroev
│ │ │ └── movie
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── khamroev
│ │ └── movie
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── settings.gradle.kts
├── README.md
├── gradle.properties
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Movie
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khamroevjs/movie-mobile/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | .idea
5 | .DS_Store
6 | /build
7 | /captures
8 | .externalNativeBuild
9 | .cxx
10 | local.properties
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/domain/model/Movie.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.domain.model
2 |
3 | data class Movie(
4 | val id: Int,
5 | val title: String,
6 | val description: String,
7 | val imageUrl: String,
8 | val releaseDate: String
9 | )
10 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
4 | networkTimeout=10000
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/data/remote/MoviesResponse.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.data.remote
2 |
3 | import com.khamroev.movie.data.remote.MovieRemote
4 |
5 | @kotlinx.serialization.Serializable
6 | internal data class MoviesResponse(
7 | val results: List
8 | )
9 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/util/Dispatcher.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.util
2 |
3 | import kotlinx.coroutines.CoroutineDispatcher
4 |
5 | //internal interface Dispatcher {
6 | // val io: CoroutineDispatcher
7 | //}
8 | //
9 | //internal expect fun provideDispatcher(): Dispatcher
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/domain/repository/MovieRepository.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.domain.repository
2 |
3 | import com.khamroev.movie.domain.model.Movie
4 |
5 | internal interface MovieRepository {
6 | suspend fun getMovies(page: Int): List
7 |
8 | suspend fun getMovie(movieId: Int): Movie
9 | }
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "Movie"
16 | include(":app")
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Movie
2 |
3 |
4 |
5 |
6 |
7 |  |
8 |  |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.di
2 |
3 | import com.khamroev.movie.detail.DetailViewModel
4 | import com.khamroev.movie.home.HomeViewModel
5 | import org.koin.androidx.viewmodel.dsl.viewModel
6 | import org.koin.dsl.module
7 |
8 | val appModule = module {
9 | viewModel { HomeViewModel(get()) }
10 | viewModel { params -> DetailViewModel(getMovieUseCase = get(), movieId = params.get()) }
11 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/data/remote/MovieRemote.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.data.remote
2 |
3 | import kotlinx.serialization.SerialName
4 |
5 | @kotlinx.serialization.Serializable
6 | internal data class MovieRemote(
7 | val id: Int,
8 | val title: String,
9 | val overview: String,
10 | @SerialName("poster_path")
11 | val posterImage: String,
12 | @SerialName("release_date")
13 | val releaseDate: String
14 | )
15 |
--------------------------------------------------------------------------------
/app/src/test/java/com/khamroev/movie/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/play_button.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/data/remote/MovieService.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.data.remote
2 |
3 | import io.ktor.client.call.*
4 | import io.ktor.client.request.*
5 |
6 | internal class MovieService: KtorApi() {
7 |
8 | suspend fun getMovies(page: Int = 1): MoviesResponse = client.get {
9 | pathUrl("movie/popular")
10 | parameter("page", page)
11 | }.body()
12 |
13 | suspend fun getMovie(movieId: Int): MovieRemote = client.get {
14 | pathUrl("movie/${movieId}")
15 | }.body()
16 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/data/util/Mappers.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.data.util
2 |
3 | import com.khamroev.movie.data.remote.MovieRemote
4 | import com.khamroev.movie.domain.model.Movie
5 |
6 | internal fun MovieRemote.toMovie(): Movie {
7 | return Movie(
8 | id = id,
9 | title = title,
10 | description = overview,
11 | imageUrl = getImageUrl(posterImage),
12 | releaseDate = releaseDate
13 | )
14 | }
15 |
16 | private fun getImageUrl(posterImage: String) = "https://image.tmdb.org/t/p/w500/$posterImage"
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/domain/usecase/GetMovieUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.domain.usecase
2 |
3 | import com.khamroev.movie.domain.model.Movie
4 | import com.khamroev.movie.domain.repository.MovieRepository
5 | import org.koin.core.component.KoinComponent
6 | import org.koin.core.component.inject
7 |
8 | class GetMovieUseCase: KoinComponent {
9 | private val repository: MovieRepository by inject()
10 |
11 | @Throws(Exception::class)
12 | suspend operator fun invoke(movieId: Int): Movie {
13 | return repository.getMovie(movieId = movieId)
14 | }
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/domain/usecase/GetMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.domain.usecase
2 |
3 | import com.khamroev.movie.domain.model.Movie
4 | import com.khamroev.movie.domain.repository.MovieRepository
5 | import org.koin.core.component.KoinComponent
6 | import org.koin.core.component.inject
7 |
8 | class GetMoviesUseCase: KoinComponent {
9 | private val repository: MovieRepository by inject()
10 |
11 | @Throws(Exception::class)
12 | suspend operator fun invoke(page: Int): List{
13 | return repository.getMovies(page = page)
14 | }
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/MovieConfig.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie
2 |
3 | import android.app.Application
4 | import com.khamroev.movie.di.appModule
5 | import com.khamroev.movie.di.getSharedModules
6 | import org.koin.android.ext.koin.androidContext
7 | import org.koin.android.ext.koin.androidLogger
8 | import org.koin.core.context.startKoin
9 |
10 | class MovieConfig: Application() {
11 | override fun onCreate() {
12 | super.onCreate()
13 | startKoin {
14 | // androidLogger()
15 | androidContext(this@MovieConfig)
16 | modules(appModule + getSharedModules())
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/data/remote/RemoteDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.data.remote
2 |
3 | //import com.khamroev.movie.util.Dispatcher
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.withContext
6 |
7 | internal class RemoteDataSource(
8 | private val apiService: MovieService,
9 | // private val dispatcher: Dispatcher
10 | ) {
11 |
12 | suspend fun getMovies(page: Int) = withContext(Dispatchers.IO){
13 | apiService.getMovies(page = page)
14 | }
15 |
16 | suspend fun getMovie(movieId: Int) = withContext(Dispatchers.IO){
17 | apiService.getMovie(movieId = movieId)
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/data/repository/MovieRepositoryImpl.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.data.repository
2 |
3 | import com.khamroev.movie.data.remote.RemoteDataSource
4 | import com.khamroev.movie.data.util.toMovie
5 | import com.khamroev.movie.domain.model.Movie
6 | import com.khamroev.movie.domain.repository.MovieRepository
7 |
8 | internal class MovieRepositoryImpl(
9 | private val remoteDateSource: RemoteDataSource
10 | ): MovieRepository {
11 |
12 | override suspend fun getMovies(page: Int): List {
13 | return remoteDateSource.getMovies(page = page).results.map {
14 | it.toMovie()
15 | }
16 | }
17 |
18 | override suspend fun getMovie(movieId: Int): Movie {
19 | return remoteDateSource.getMovie(movieId = movieId).toMovie()
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/khamroev/movie/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.khamroev.movie", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.kts.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/data/remote/KtorApi.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.data.remote
2 |
3 | import io.ktor.client.*
4 | import io.ktor.client.plugins.contentnegotiation.*
5 | import io.ktor.client.request.*
6 | import io.ktor.http.*
7 | import io.ktor.serialization.kotlinx.json.*
8 | import kotlinx.serialization.json.Json
9 |
10 | private const val BASE_URL = "https://api.themoviedb.org/"
11 | private const val API_KEY = "YOUR_API_KEY"
12 |
13 | internal abstract class KtorApi {
14 | val client = HttpClient {
15 | install(ContentNegotiation){
16 | json(Json {
17 | ignoreUnknownKeys = true
18 | useAlternativeNames = false
19 | })
20 | }
21 | }
22 |
23 |
24 | fun HttpRequestBuilder.pathUrl(path: String){
25 | url {
26 | takeFrom(BASE_URL)
27 | path("3", path)
28 | parameter("api_key", API_KEY)
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/di/SharedModules.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.di
2 |
3 | import com.khamroev.movie.data.remote.MovieService
4 | import com.khamroev.movie.data.remote.RemoteDataSource
5 | import com.khamroev.movie.data.repository.MovieRepositoryImpl
6 | import com.khamroev.movie.domain.repository.MovieRepository
7 | import com.khamroev.movie.domain.usecase.GetMovieUseCase
8 | import com.khamroev.movie.domain.usecase.GetMoviesUseCase
9 | import org.koin.dsl.module
10 |
11 | private val dataModule = module {
12 | factory { RemoteDataSource(get()) }
13 | factory { MovieService() }
14 | }
15 |
16 | private val domainModule = module {
17 | single { MovieRepositoryImpl(get()) }
18 | factory { GetMoviesUseCase() }
19 | factory { GetMovieUseCase() }
20 | }
21 |
22 | private val sharedModules = listOf(domainModule, dataModule)
23 |
24 | fun getSharedModules() = sharedModules
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/common/Destinations.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.common
2 |
3 | import androidx.navigation.NavType
4 | import androidx.navigation.navArgument
5 |
6 | interface Destination {
7 | val title: String
8 | val route: String
9 | val routeWithArgs: String
10 | }
11 |
12 | object Home: Destination {
13 | override val title: String
14 | get() = "Movies"
15 |
16 | override val route: String
17 | get() = "home"
18 |
19 | override val routeWithArgs: String
20 | get() = route
21 | }
22 |
23 | object Detail: Destination {
24 | override val title: String
25 | get() = "Movie details"
26 |
27 | override val route: String
28 | get() = "detail"
29 |
30 | override val routeWithArgs: String
31 | get() = "$route/{movieId}"
32 |
33 | val arguments = listOf(
34 | navArgument(name = "movieId"){type = NavType.IntType}
35 | )
36 | }
37 |
38 | val movieDestinations = listOf(Home, Detail)
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.Surface
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.tooling.preview.Preview
12 |
13 | class MainActivity : ComponentActivity() {
14 | override fun onCreate(savedInstanceState: Bundle?) {
15 | super.onCreate(savedInstanceState)
16 | setContent {
17 | MovieTheme {
18 | Surface(
19 | modifier = Modifier.fillMaxSize(),
20 | color = MaterialTheme.colorScheme.background
21 | ) {
22 | MovieApp()
23 | }
24 | }
25 | }
26 | }
27 | }
28 |
29 | @Preview(showBackground = true)
30 | @Composable
31 | fun MoviePreview() {
32 | MovieTheme {
33 | Surface(
34 | modifier = Modifier.fillMaxSize(),
35 | color = MaterialTheme.colorScheme.background
36 | ) {
37 | MovieApp()
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/detail/DetailViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.detail
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.ViewModel
7 | import androidx.lifecycle.viewModelScope
8 | import com.khamroev.movie.domain.model.Movie
9 | import com.khamroev.movie.domain.usecase.GetMovieUseCase
10 | import kotlinx.coroutines.launch
11 |
12 | class DetailViewModel(
13 | val getMovieUseCase: GetMovieUseCase,
14 | val movieId: Int
15 | ): ViewModel(){
16 | var uiState by mutableStateOf(DetailScreenState())
17 |
18 | init {
19 | loadMovie(movieId)
20 | }
21 |
22 | private fun loadMovie(movieId: Int){
23 | viewModelScope.launch {
24 | uiState = uiState.copy(loading = true)
25 |
26 | uiState = try {
27 | val movie = getMovieUseCase(movieId = movieId)
28 | uiState.copy(loading = false, movie = movie)
29 | }catch (error: Throwable){
30 | uiState.copy(
31 | loading = false,
32 | errorMessage = "Could not load the movie"
33 | )
34 | }
35 | }
36 | }
37 | }
38 |
39 | data class DetailScreenState(
40 | var loading: Boolean = false,
41 | var movie: Movie? = null,
42 | var errorMessage: String? = null
43 | )
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/MovieTheme.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material3.MaterialTheme
5 | import androidx.compose.material3.Shapes
6 | import androidx.compose.material3.Typography
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.lightColorScheme
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.graphics.Color
11 | import androidx.compose.ui.text.TextStyle
12 | import androidx.compose.ui.text.font.FontFamily
13 | import androidx.compose.ui.text.font.FontWeight
14 | import androidx.compose.ui.unit.dp
15 | import androidx.compose.ui.unit.sp
16 |
17 | @Composable
18 | fun MovieTheme(
19 | darkTheme: Boolean = true,
20 | content: @Composable () -> Unit
21 | ) {
22 | val colors = if (darkTheme) {
23 | darkColorScheme(
24 | primary = Dark4,
25 | secondary = Dark3,
26 | tertiary = Red,
27 | surface = Dark2,
28 | background = Dark1
29 | )
30 | } else {
31 | lightColorScheme(
32 | primary = Color(0xFF6200EE),
33 | secondary = Color(0xFF3700B3),
34 | tertiary = Color(0xFF03DAC5)
35 | )
36 | }
37 | val typography = Typography(
38 | bodyLarge = TextStyle(
39 | fontFamily = FontFamily.Default,
40 | fontWeight = FontWeight.Normal,
41 | fontSize = 16.sp
42 | )
43 | )
44 | val shapes = Shapes(
45 | small = RoundedCornerShape(4.dp),
46 | medium = RoundedCornerShape(4.dp),
47 | large = RoundedCornerShape(0.dp)
48 | )
49 |
50 | MaterialTheme(
51 | colorScheme = colors,
52 | typography = typography,
53 | shapes = shapes,
54 | content = content
55 | )
56 | }
57 |
58 | val Dark1 = Color(red = 22, green = 23, blue = 29)
59 | val Dark2 = Color(red = 28, green = 29, blue = 35)
60 | val Dark3 = Color(red = 31, green = 31, blue = 36)
61 | val Dark4 = Color(red = 34, green = 35, blue = 40)
62 | val Red = Color(red = 220, green = 0, blue = 59)
63 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/home/HomeViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.home
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.ViewModel
7 | import androidx.lifecycle.viewModelScope
8 | import com.khamroev.movie.domain.model.Movie
9 | import com.khamroev.movie.domain.usecase.GetMoviesUseCase
10 | import kotlinx.coroutines.launch
11 |
12 | class HomeViewModel(
13 | val getMoviesUseCase: GetMoviesUseCase
14 | ): ViewModel(){
15 | var uiState by mutableStateOf(HomeScreenState())
16 | private var currentPage = 1
17 |
18 | init {
19 | loadMovies(forceReload = false)
20 | }
21 |
22 |
23 | fun loadMovies(forceReload: Boolean = false){
24 | if (uiState.loading) return
25 | if (forceReload) currentPage = 1
26 | if (currentPage == 1) uiState = uiState.copy(refreshing = true)
27 |
28 | viewModelScope.launch {
29 | uiState = uiState.copy(
30 | loading = true
31 | )
32 |
33 | try {
34 | val resultMovies = getMoviesUseCase(page = currentPage)
35 | val movies = if (currentPage == 1) resultMovies else uiState.movies + resultMovies
36 |
37 | currentPage += 1
38 | uiState = uiState.copy(
39 | loading = false,
40 | refreshing = false,
41 | loadFinished = resultMovies.isEmpty(),
42 | movies = movies
43 | )
44 |
45 | }catch (error: Throwable){
46 | uiState = uiState.copy(
47 | loading = false,
48 | refreshing = false,
49 | loadFinished = true,
50 | errorMessage = "Could not load movies: ${error.localizedMessage}"
51 | )
52 | }
53 | }
54 | }
55 | }
56 |
57 |
58 |
59 |
60 | data class HomeScreenState(
61 | var loading: Boolean = false,
62 | var refreshing: Boolean = false,
63 | var movies: List = listOf(),
64 | var errorMessage: String? = null,
65 | var loadFinished: Boolean = false
66 | )
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/common/MovieAppBar.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.common
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.height
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.layout.width
10 | import androidx.compose.material.icons.Icons
11 | import androidx.compose.material.icons.filled.ArrowBack
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.material3.IconButton
14 | import androidx.compose.material3.MaterialTheme
15 | import androidx.compose.material3.Surface
16 | import androidx.compose.material3.Text
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.ui.Alignment
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.unit.dp
21 |
22 | @Composable
23 | fun MovieAppBar(
24 | modifier: Modifier = Modifier,
25 | canNavigateBack: Boolean,
26 | currentScreen: Destination,
27 | onNavigateBack: () -> Unit
28 | ) {
29 | Surface(
30 | modifier = modifier
31 | .fillMaxWidth()
32 | .height(56.dp),
33 | shadowElevation = 4.dp,
34 | tonalElevation = 4.dp,
35 | color = MaterialTheme.colorScheme.primary
36 | ) {
37 | Row(
38 | modifier = modifier.padding(start = 8.dp),
39 | verticalAlignment = Alignment.CenterVertically
40 | ) {
41 | AnimatedVisibility(visible = canNavigateBack) {
42 | IconButton(onClick = onNavigateBack) {
43 | Icon(
44 | imageVector = Icons.Filled.ArrowBack,
45 | contentDescription = null,
46 | tint = MaterialTheme.colorScheme.onBackground
47 | )
48 | }
49 | Spacer(modifier = modifier.width(24.dp))
50 | }
51 |
52 | Text(
53 | text = currentScreen.title,
54 | style = MaterialTheme.typography.titleLarge,
55 | modifier = modifier.padding(12.dp),
56 | color = MaterialTheme.colorScheme.onSurface
57 | )
58 | }
59 | }
60 | }
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | kotlin("android")
4 | kotlin("plugin.serialization") version "1.8.21"
5 | }
6 |
7 | android {
8 | namespace = "com.khamroev.movie"
9 | compileSdk = 33
10 |
11 | defaultConfig {
12 | applicationId = "com.khamroev.movie"
13 | minSdk = 26
14 | targetSdk = 33
15 | versionCode = 1
16 | versionName = "1.0"
17 |
18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19 | vectorDrawables {
20 | useSupportLibrary = true
21 | }
22 | }
23 |
24 | buildTypes {
25 | getByName("release") {
26 | isMinifyEnabled = false
27 | proguardFiles(
28 | getDefaultProguardFile("proguard-android-optimize.txt"),
29 | "proguard-rules.pro"
30 | )
31 | }
32 | }
33 |
34 | compileOptions {
35 | sourceCompatibility = JavaVersion.VERSION_1_8
36 | targetCompatibility = JavaVersion.VERSION_1_8
37 | }
38 | kotlinOptions {
39 | jvmTarget = "1.8"
40 | }
41 | buildFeatures {
42 | compose = true
43 | }
44 | composeOptions {
45 | kotlinCompilerExtensionVersion = "1.4.7"
46 | }
47 | packaging {
48 | resources {
49 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
50 | }
51 | }
52 | }
53 |
54 | dependencies {
55 | val ktorVersion = "2.3.0"
56 | val koinComposeVersion = "3.4.4"
57 | val coilVersion = "2.4.0"
58 | val accompanistVersion = "0.30.1"
59 |
60 | implementation("io.ktor:ktor-client-android:$ktorVersion")
61 | implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
62 | implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
63 |
64 | implementation("io.insert-koin:koin-androidx-compose:$koinComposeVersion")
65 | implementation("io.coil-kt:coil-compose:$coilVersion")
66 | implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion")
67 |
68 | implementation("androidx.navigation:navigation-compose:2.5.3")
69 | implementation("androidx.compose.ui:ui-tooling")
70 | implementation("androidx.compose.ui:ui-tooling-preview")
71 | implementation("androidx.compose.foundation:foundation")
72 | implementation("androidx.compose.material3:material3:1.1.0")
73 | implementation("androidx.activity:activity-compose:1.7.1")
74 |
75 | testImplementation("junit:junit:4.13.2")
76 | androidTestImplementation("androidx.compose.ui:ui-test-junit4")
77 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/pullrefresh/PullRefreshIndicatorTransform.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.pullrefresh
2 |
3 | import androidx.compose.animation.core.LinearOutSlowInEasing
4 | import androidx.compose.ui.Modifier
5 | import androidx.compose.ui.draw.drawWithContent
6 | import androidx.compose.ui.graphics.drawscope.clipRect
7 | import androidx.compose.ui.graphics.graphicsLayer
8 | import androidx.compose.ui.platform.debugInspectorInfo
9 | import androidx.compose.ui.platform.inspectable
10 |
11 | /**
12 | * A modifier for translating the position and scaling the size of a pull-to-refresh indicator
13 | * based on the given [PullRefreshState].
14 | *
15 | * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample
16 | *
17 | * @param state The [PullRefreshState] which determines the position of the indicator.
18 | * @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
19 | */
20 | // TODO: Consider whether the state parameter should be replaced with lambdas.
21 | fun Modifier.pullRefreshIndicatorTransform(
22 | state: PullRefreshState,
23 | scale: Boolean = false,
24 | ) = inspectable(inspectorInfo = debugInspectorInfo {
25 | name = "pullRefreshIndicatorTransform"
26 | properties["state"] = state
27 | properties["scale"] = scale
28 | }) {
29 | Modifier
30 | // Essentially we only want to clip the at the top, so the indicator will not appear when
31 | // the position is 0. It is preferable to clip the indicator as opposed to the layout that
32 | // contains the indicator, as this would also end up clipping shadows drawn by items in a
33 | // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE
34 | // for the other dimensions to allow for more room for elevation / arbitrary indicators - we
35 | // only ever really want to clip at the top edge.
36 | .drawWithContent {
37 | clipRect(
38 | top = 0f,
39 | left = -Float.MAX_VALUE,
40 | right = Float.MAX_VALUE,
41 | bottom = Float.MAX_VALUE
42 | ) {
43 | this@drawWithContent.drawContent()
44 | }
45 | }
46 | .graphicsLayer {
47 | translationY = state.position - size.height
48 |
49 | if (scale && !state.refreshing) {
50 | val scaleFraction = LinearOutSlowInEasing
51 | .transform(state.position / state.threshold)
52 | .coerceIn(0f, 1f)
53 | scaleX = scaleFraction
54 | scaleY = scaleFraction
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo.
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
48 | echo.
49 | echo Please set the JAVA_HOME variable in your environment to match the
50 | echo location of your Java installation.
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo.
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
62 | echo.
63 | echo Please set the JAVA_HOME variable in your environment to match the
64 | echo location of your Java installation.
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/home/MovieListItem.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.home
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.foundation.shape.CircleShape
7 | import androidx.compose.foundation.shape.RoundedCornerShape
8 | import androidx.compose.material3.Card
9 | import androidx.compose.material3.MaterialTheme
10 | import androidx.compose.material3.Surface
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.draw.clip
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.layout.ContentScale
18 | import androidx.compose.ui.res.painterResource
19 | import androidx.compose.ui.text.font.FontWeight
20 | import androidx.compose.ui.text.style.TextOverflow
21 | import androidx.compose.ui.unit.dp
22 | import coil.compose.AsyncImage
23 | import com.khamroev.movie.R
24 | import com.khamroev.movie.domain.model.Movie
25 |
26 | @Composable
27 | fun MovieListItem(
28 | modifier: Modifier = Modifier,
29 | movie: Movie,
30 | onMovieClick: (Movie) -> Unit
31 | ) {
32 | Card(
33 | modifier = modifier
34 | .height(220.dp)
35 | .clickable { onMovieClick(movie) },
36 | shape = RoundedCornerShape(8.dp)
37 | ) {
38 | Column {
39 | Box(
40 | modifier = modifier.weight(1f),
41 | contentAlignment = Alignment.Center
42 | ) {
43 | AsyncImage(
44 | model = movie.imageUrl,
45 | contentDescription = null,
46 | contentScale = ContentScale.Crop,
47 | modifier = modifier
48 | .fillMaxSize()
49 | .clip(RoundedCornerShape(bottomStart = 2.dp, bottomEnd = 2.dp))
50 | )
51 |
52 | Surface(
53 | color = Color.Black.copy(alpha = 0.6f),
54 | modifier = modifier
55 | .size(50.dp),
56 | shape = CircleShape
57 | ) {
58 | Image(
59 | painter = painterResource(id = R.drawable.play_button),
60 | contentDescription = null,
61 | modifier = modifier.padding(12.dp).align(Alignment.Center)
62 | )
63 | }
64 | }
65 |
66 | Column(
67 | modifier = modifier.padding(10.dp)
68 | ){
69 | Text(
70 | text = movie.title,
71 | style = MaterialTheme.typography.titleMedium,
72 | fontWeight = FontWeight.Bold,
73 | maxLines = 1,
74 | overflow = TextOverflow.Ellipsis
75 | )
76 | Spacer(modifier = modifier.height(4.dp))
77 |
78 | Text(text = movie.releaseDate, style = MaterialTheme.typography.bodySmall)
79 | }
80 | }
81 | }
82 |
83 | }
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/home/HomeScreen.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.home
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.PaddingValues
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.lazy.grid.GridCells
12 | import androidx.compose.foundation.lazy.grid.GridItemSpan
13 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
14 | import androidx.compose.foundation.lazy.grid.itemsIndexed
15 | import androidx.compose.material3.CircularProgressIndicator
16 | import androidx.compose.material3.MaterialTheme
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.runtime.LaunchedEffect
19 | import androidx.compose.ui.Alignment
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.unit.dp
22 | import com.khamroev.movie.pullrefresh.PullRefreshIndicator
23 | import com.khamroev.movie.pullrefresh.pullRefresh
24 | import com.khamroev.movie.pullrefresh.rememberPullRefreshState
25 | import com.khamroev.movie.Red
26 | import com.khamroev.movie.domain.model.Movie
27 |
28 | @Composable
29 | fun HomeScreen(
30 | modifier: Modifier = Modifier,
31 | uiState: HomeScreenState,
32 | loadNextMovies: (Boolean) -> Unit,
33 | navigateToDetail: (Movie) -> Unit
34 | ) {
35 | val pullRefreshState = rememberPullRefreshState(
36 | refreshing = uiState.refreshing,
37 | onRefresh = { loadNextMovies(true) })
38 |
39 | Box(
40 | modifier = modifier
41 | .fillMaxSize()
42 | .background(color = MaterialTheme.colorScheme.background)
43 | .pullRefresh(state = pullRefreshState)
44 | ) {
45 | LazyVerticalGrid(
46 | columns = GridCells.Fixed(2),
47 | contentPadding = PaddingValues(16.dp),
48 | horizontalArrangement = Arrangement.spacedBy(16.dp),
49 | verticalArrangement = Arrangement.spacedBy(16.dp)
50 | ) {
51 | itemsIndexed(
52 | uiState.movies,
53 | key = { _, movie -> movie.id }
54 | ) { index, movie ->
55 | MovieListItem(movie = movie, onMovieClick = { navigateToDetail(movie) })
56 |
57 | if (index >= uiState.movies.size - 1 && !uiState.loading && !uiState.loadFinished) {
58 | LaunchedEffect(key1 = Unit, block = { loadNextMovies(false) })
59 | }
60 | }
61 |
62 | if (uiState.loading && uiState.movies.isNotEmpty()) {
63 | item(span = { GridItemSpan(2) }) {
64 | Row(
65 | modifier = modifier
66 | .fillMaxWidth()
67 | .padding(16.dp),
68 | horizontalArrangement = Arrangement.Center,
69 | verticalAlignment = Alignment.CenterVertically
70 | ) {
71 | CircularProgressIndicator(
72 | color = Red
73 | )
74 | }
75 | }
76 | }
77 | }
78 |
79 | PullRefreshIndicator(
80 | refreshing = uiState.refreshing,
81 | state = pullRefreshState,
82 | modifier = modifier.align(Alignment.TopCenter)
83 | )
84 | }
85 | }
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/MovieApp.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.Scaffold
7 | import androidx.compose.material3.SnackbarHost
8 | import androidx.compose.material3.SnackbarHostState
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.SideEffect
11 | import androidx.compose.runtime.getValue
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.graphics.Color
15 | import androidx.navigation.compose.NavHost
16 | import androidx.navigation.compose.composable
17 | import androidx.navigation.compose.currentBackStackEntryAsState
18 | import androidx.navigation.compose.rememberNavController
19 | import com.google.accompanist.systemuicontroller.rememberSystemUiController
20 | import com.khamroev.movie.common.Detail
21 | import com.khamroev.movie.common.Home
22 | import com.khamroev.movie.common.MovieAppBar
23 | import com.khamroev.movie.common.movieDestinations
24 | import com.khamroev.movie.detail.DetailScreen
25 | import com.khamroev.movie.detail.DetailViewModel
26 | import com.khamroev.movie.home.HomeScreen
27 | import com.khamroev.movie.home.HomeViewModel
28 | import org.koin.androidx.compose.koinViewModel
29 | import org.koin.core.parameter.parametersOf
30 |
31 | @Composable
32 | fun MovieApp() {
33 | val navController = rememberNavController()
34 | val systemUiController = rememberSystemUiController()
35 | val snackbarHostState = remember { SnackbarHostState() }
36 |
37 | val isSystemDark = isSystemInDarkTheme()
38 | val statusBarColor = if (isSystemDark){
39 | MaterialTheme.colorScheme.secondary
40 | }else {
41 | Color.Transparent
42 | }
43 | SideEffect {
44 | systemUiController.setStatusBarColor(statusBarColor, darkIcons = !isSystemDark)
45 | }
46 |
47 | val backStackEntry by navController.currentBackStackEntryAsState()
48 | val currentScreen = movieDestinations.find {
49 | backStackEntry?.destination?.route == it.route ||
50 | backStackEntry?.destination?.route == it.routeWithArgs
51 | }?: Home
52 |
53 | Scaffold(
54 | snackbarHost = { SnackbarHost(snackbarHostState) },
55 | topBar = {
56 | MovieAppBar(
57 | canNavigateBack = navController.previousBackStackEntry != null,
58 | currentScreen = currentScreen
59 | ) {
60 | navController.navigateUp()
61 | }
62 | }
63 | ) {innerPaddings ->
64 | NavHost(
65 | navController = navController,
66 | modifier = Modifier.padding(innerPaddings),
67 | startDestination = Home.routeWithArgs
68 | ){
69 | composable(Home.routeWithArgs){
70 | val homeViewModel: HomeViewModel = koinViewModel()
71 | HomeScreen(
72 | uiState = homeViewModel.uiState,
73 | loadNextMovies = {
74 | homeViewModel.loadMovies(forceReload = it)
75 | },
76 | navigateToDetail = {
77 | navController.navigate(
78 | "${Detail.route}/${it.id}"
79 | )
80 | }
81 | )
82 | }
83 |
84 | composable(Detail.routeWithArgs, arguments = Detail.arguments){
85 | val movieId = it.arguments?.getInt("movieId") ?: 0
86 | val detailViewModel: DetailViewModel = koinViewModel(
87 | parameters = { parametersOf(movieId) }
88 | )
89 |
90 | DetailScreen(uiState = detailViewModel.uiState)
91 | }
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/detail/DetailScreen.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.detail
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.material3.Button
7 | import androidx.compose.material3.ButtonDefaults
8 | import androidx.compose.material3.CircularProgressIndicator
9 | import androidx.compose.material3.Icon
10 | import androidx.compose.material3.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.graphics.Color
15 | import androidx.compose.ui.layout.ContentScale
16 | import androidx.compose.ui.res.painterResource
17 | import androidx.compose.ui.text.font.FontWeight
18 | import androidx.compose.ui.unit.dp
19 | import coil.compose.AsyncImage
20 | import com.khamroev.movie.R
21 | import com.khamroev.movie.Red
22 |
23 | @Composable
24 | fun DetailScreen(
25 | modifier: Modifier = Modifier,
26 | uiState: DetailScreenState
27 | ) {
28 | Box(
29 | contentAlignment = Alignment.Center
30 | ) {
31 | uiState.movie?.let { movie ->
32 | Column(
33 | modifier = modifier
34 | .fillMaxSize()
35 | .background(color = MaterialTheme.colorScheme.background)
36 | ) {
37 | AsyncImage(
38 | model = movie.imageUrl,
39 | contentDescription = null,
40 | contentScale = ContentScale.Crop,
41 | modifier = modifier
42 | .fillMaxWidth()
43 | .height(320.dp)
44 | )
45 |
46 | Column(
47 | modifier = modifier
48 | .fillMaxWidth()
49 | .weight(1f)
50 | .padding(20.dp)
51 | ) {
52 | Text(
53 | text = movie.title,
54 | style = MaterialTheme.typography.headlineSmall,
55 | fontWeight = FontWeight.Bold
56 | )
57 | Spacer(modifier = modifier.height(8.dp))
58 |
59 | Button(
60 | onClick = {},
61 | modifier = modifier.fillMaxWidth().height(46.dp),
62 | colors = ButtonDefaults.buttonColors(containerColor = Red),
63 | elevation = ButtonDefaults.buttonElevation(0.dp)
64 | ) {
65 | Icon(
66 | painter = painterResource(id = R.drawable.play_button),
67 | contentDescription = null,
68 | tint = Color.White
69 | )
70 | Spacer(modifier = modifier.width(8.dp))
71 |
72 | Text(text = "Start watching now", color = Color.White)
73 | }
74 |
75 | Spacer(modifier = modifier.height(16.dp))
76 |
77 | Text(
78 | text = "Released in ${movie.releaseDate}".uppercase(),
79 | style = MaterialTheme.typography.labelSmall
80 | )
81 |
82 | Spacer(modifier = modifier.height(4.dp))
83 |
84 | Text(text = movie.description, style = MaterialTheme.typography.bodyMedium)
85 | }
86 | }
87 | }
88 |
89 | if (uiState.loading) {
90 | Row(
91 | modifier = modifier.fillMaxSize(),
92 | verticalAlignment = Alignment.CenterVertically,
93 | horizontalArrangement = Arrangement.Center
94 | ) {
95 | CircularProgressIndicator(
96 | color = Red
97 | )
98 | }
99 | }
100 | }
101 | }
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/pullrefresh/PullRefresh.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.pullrefresh
2 |
3 | import androidx.compose.ui.Modifier
4 | import androidx.compose.ui.geometry.Offset
5 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
6 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource
7 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag
8 | import androidx.compose.ui.input.nestedscroll.nestedScroll
9 | import androidx.compose.ui.platform.debugInspectorInfo
10 | import androidx.compose.ui.platform.inspectable
11 | import androidx.compose.ui.unit.Velocity
12 |
13 | /**
14 | * A nested scroll modifier that provides scroll events to [state].
15 | *
16 | * Note that this modifier must be added above a scrolling container, such as a lazy column, in
17 | * order to receive scroll events. For example:
18 | *
19 | * @sample androidx.compose.material.samples.PullRefreshSample
20 | *
21 | * @param state The [PullRefreshState] associated with this pull-to-refresh component.
22 | * The state will be updated by this modifier.
23 | * @param enabled If not enabled, all scroll delta and fling velocity will be ignored.
24 | */
25 | // TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple.
26 | fun Modifier.pullRefresh(
27 | state: PullRefreshState,
28 | enabled: Boolean = true,
29 | ) = inspectable(inspectorInfo = debugInspectorInfo {
30 | name = "pullRefresh"
31 | properties["state"] = state
32 | properties["enabled"] = enabled
33 | }) {
34 | Modifier.pullRefresh(state::onPull, state::onRelease, enabled)
35 | }
36 |
37 | /**
38 | * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom
39 | * pull refresh components.
40 | *
41 | * Note that this modifier must be added above a scrolling container, such as a lazy column, in
42 | * order to receive scroll events. For example:
43 | *
44 | * @sample androidx.compose.material.samples.CustomPullRefreshSample
45 | *
46 | * @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument.
47 | * Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling
48 | * down despite being at the top of a scrollable component), whereas negative delta (swiping up) is
49 | * dispatched first (in case it is needed to push the indicator back up), and then the unconsumed
50 | * delta is passed on to the child. The callback returns how much delta was consumed.
51 | * @param onRelease Callback for when drag is released, takes float flingVelocity as argument.
52 | * The callback returns how much velocity was consumed - in most cases this should only consume
53 | * velocity if pull refresh has been dragged already and the velocity is positive (the fling is
54 | * downwards), as an upwards fling should typically still scroll a scrollable component beneath the
55 | * pullRefresh. This is invoked before any remaining velocity is passed to the child.
56 | * @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither
57 | * [onPull] nor [onRelease] will be invoked.
58 | */
59 | fun Modifier.pullRefresh(
60 | onPull: (pullDelta: Float) -> Float,
61 | onRelease: suspend (flingVelocity: Float) -> Float,
62 | enabled: Boolean = true,
63 | ) = inspectable(inspectorInfo = debugInspectorInfo {
64 | name = "pullRefresh"
65 | properties["onPull"] = onPull
66 | properties["onRelease"] = onRelease
67 | properties["enabled"] = enabled
68 | }) {
69 | Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
70 | }
71 |
72 | private class PullRefreshNestedScrollConnection(
73 | private val onPull: (pullDelta: Float) -> Float,
74 | private val onRelease: suspend (flingVelocity: Float) -> Float,
75 | private val enabled: Boolean,
76 | ) : NestedScrollConnection {
77 |
78 | override fun onPreScroll(
79 | available: Offset,
80 | source: NestedScrollSource,
81 | ): Offset = when {
82 | !enabled -> Offset.Zero
83 | source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
84 | else -> Offset.Zero
85 | }
86 |
87 | override fun onPostScroll(
88 | consumed: Offset,
89 | available: Offset,
90 | source: NestedScrollSource,
91 | ): Offset = when {
92 | !enabled -> Offset.Zero
93 | source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
94 | else -> Offset.Zero
95 | }
96 |
97 | override suspend fun onPreFling(available: Velocity): Velocity {
98 | return Velocity(0f, onRelease(available.y))
99 | }
100 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/pullrefresh/PullRefreshIndicator.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.pullrefresh
2 |
3 | import androidx.compose.animation.Crossfade
4 | import androidx.compose.animation.core.LinearEasing
5 | import androidx.compose.animation.core.animateFloatAsState
6 | import androidx.compose.animation.core.tween
7 | import androidx.compose.foundation.Canvas
8 | import androidx.compose.foundation.layout.Box
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.size
11 | import androidx.compose.foundation.shape.CircleShape
12 | import androidx.compose.material3.CircularProgressIndicator
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.material3.Surface
15 | import androidx.compose.material3.contentColorFor
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.runtime.Immutable
18 | import androidx.compose.runtime.derivedStateOf
19 | import androidx.compose.runtime.getValue
20 | import androidx.compose.runtime.remember
21 | import androidx.compose.ui.Alignment
22 | import androidx.compose.ui.Modifier
23 | import androidx.compose.ui.geometry.Offset
24 | import androidx.compose.ui.geometry.Rect
25 | import androidx.compose.ui.geometry.center
26 | import androidx.compose.ui.graphics.Color
27 | import androidx.compose.ui.graphics.Path
28 | import androidx.compose.ui.graphics.PathFillType
29 | import androidx.compose.ui.graphics.StrokeCap
30 | import androidx.compose.ui.graphics.drawscope.DrawScope
31 | import androidx.compose.ui.graphics.drawscope.Stroke
32 | import androidx.compose.ui.graphics.drawscope.rotate
33 | import androidx.compose.ui.semantics.semantics
34 | import androidx.compose.ui.unit.dp
35 | import kotlin.math.abs
36 | import kotlin.math.max
37 | import kotlin.math.min
38 | import kotlin.math.pow
39 |
40 | /**
41 | * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout.
42 | *
43 | * @sample androidx.compose.material.samples.PullRefreshSample
44 | *
45 | * @param refreshing A boolean representing whether a refresh is occurring.
46 | * @param state The [PullRefreshState] which controls where and how the indicator will be drawn.
47 | * @param modifier Modifiers for the indicator.
48 | * @param backgroundColor The color of the indicator's background.
49 | * @param contentColor The color of the indicator's arc and arrow.
50 | * @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
51 | */
52 | @Composable
53 | // TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to
54 | // enable people to use this indicator with custom pull-to-refresh components.
55 | fun PullRefreshIndicator(
56 | refreshing: Boolean,
57 | state: PullRefreshState,
58 | modifier: Modifier = Modifier,
59 | backgroundColor: Color = MaterialTheme.colorScheme.surface,
60 | contentColor: Color = contentColorFor(backgroundColor),
61 | scale: Boolean = false,
62 | ) {
63 | val showElevation by remember(refreshing, state) {
64 | derivedStateOf { refreshing || state.position > 0.5f }
65 | }
66 |
67 | Surface(
68 | modifier = modifier
69 | .size(IndicatorSize)
70 | .pullRefreshIndicatorTransform(state, scale),
71 | shape = SpinnerShape,
72 | color = backgroundColor,
73 | shadowElevation = if (showElevation) Elevation else 0.dp,
74 | ) {
75 | Crossfade(
76 | targetState = refreshing,
77 | animationSpec = tween(durationMillis = CrossfadeDurationMs)
78 | ) { refreshing ->
79 | Box(
80 | modifier = Modifier.fillMaxSize(),
81 | contentAlignment = Alignment.Center
82 | ) {
83 | val spinnerSize = (ArcRadius + StrokeWidth).times(2)
84 |
85 | if (refreshing) {
86 | CircularProgressIndicator(
87 | color = contentColor,
88 | strokeWidth = StrokeWidth,
89 | modifier = Modifier.size(spinnerSize),
90 | )
91 | } else {
92 | CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))
93 | }
94 | }
95 | }
96 | }
97 | }
98 |
99 | /**
100 | * Modifier.size MUST be specified.
101 | */
102 | @Composable
103 | private fun CircularArrowIndicator(
104 | state: PullRefreshState,
105 | color: Color,
106 | modifier: Modifier,
107 | ) {
108 | val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
109 |
110 | val targetAlpha by remember(state) {
111 | derivedStateOf {
112 | if (state.progress >= 1f) MaxAlpha else MinAlpha
113 | }
114 | }
115 |
116 | val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)
117 |
118 | // Empty semantics for tests
119 | Canvas(modifier.semantics {}) {
120 | val values = ArrowValues(state.progress)
121 | val alpha = alphaState.value
122 |
123 | rotate(degrees = values.rotation) {
124 | val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f
125 | val arcBounds = Rect(
126 | size.center.x - arcRadius,
127 | size.center.y - arcRadius,
128 | size.center.x + arcRadius,
129 | size.center.y + arcRadius
130 | )
131 | drawArc(
132 | color = color,
133 | alpha = alpha,
134 | startAngle = values.startAngle,
135 | sweepAngle = values.endAngle - values.startAngle,
136 | useCenter = false,
137 | topLeft = arcBounds.topLeft,
138 | size = arcBounds.size,
139 | style = Stroke(
140 | width = StrokeWidth.toPx(),
141 | cap = StrokeCap.Square
142 | )
143 | )
144 | drawArrow(path, arcBounds, color, alpha, values)
145 | }
146 | }
147 | }
148 |
149 | @Immutable
150 | private class ArrowValues(
151 | val rotation: Float,
152 | val startAngle: Float,
153 | val endAngle: Float,
154 | val scale: Float,
155 | )
156 |
157 | private fun ArrowValues(progress: Float): ArrowValues {
158 | // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%.
159 | val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3
160 | // How far beyond the threshold pull has gone, as a percentage of the threshold.
161 | val overshootPercent = abs(progress) - 1.0f
162 | // Limit the overshoot to 200%. Linear between 0 and 200.
163 | val linearTension = overshootPercent.coerceIn(0f, 2f)
164 | // Non-linear tension. Increases with linearTension, but at a decreasing rate.
165 | val tensionPercent = linearTension - linearTension.pow(2) / 4
166 |
167 | // Calculations based on SwipeRefreshLayout specification.
168 | val endTrim = adjustedPercent * MaxProgressArc
169 | val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f
170 | val startAngle = rotation * 360
171 | val endAngle = (rotation + endTrim) * 360
172 | val scale = min(1f, adjustedPercent)
173 |
174 | return ArrowValues(rotation, startAngle, endAngle, scale)
175 | }
176 |
177 | private fun DrawScope.drawArrow(
178 | arrow: Path,
179 | bounds: Rect,
180 | color: Color,
181 | alpha: Float,
182 | values: ArrowValues,
183 | ) {
184 | arrow.reset()
185 | arrow.moveTo(0f, 0f) // Move to left corner
186 | arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner
187 |
188 | // Line to tip of arrow
189 | arrow.lineTo(
190 | x = ArrowWidth.toPx() * values.scale / 2,
191 | y = ArrowHeight.toPx() * values.scale
192 | )
193 |
194 | val radius = min(bounds.width, bounds.height) / 2f
195 | val inset = ArrowWidth.toPx() * values.scale / 2f
196 | arrow.translate(
197 | Offset(
198 | x = radius + bounds.center.x - inset,
199 | y = bounds.center.y + StrokeWidth.toPx() / 2f
200 | )
201 | )
202 | arrow.close()
203 | rotate(degrees = values.endAngle) {
204 | drawPath(path = arrow, color = color, alpha = alpha)
205 | }
206 | }
207 |
208 | private const val CrossfadeDurationMs = 100
209 | private const val MaxProgressArc = 0.8f
210 |
211 | private val IndicatorSize = 40.dp
212 | private val SpinnerShape = CircleShape
213 | private val ArcRadius = 7.5.dp
214 | private val StrokeWidth = 2.5.dp
215 | private val ArrowWidth = 10.dp
216 | private val ArrowHeight = 5.dp
217 | private val Elevation = 6.dp
218 |
219 | // Values taken from SwipeRefreshLayout
220 | private const val MinAlpha = 0.3f
221 | private const val MaxAlpha = 1f
222 | private val AlphaTween = tween(300, easing = LinearEasing)
--------------------------------------------------------------------------------
/app/src/main/java/com/khamroev/movie/pullrefresh/PullRefreshState.kt:
--------------------------------------------------------------------------------
1 | package com.khamroev.movie.pullrefresh
2 |
3 | import androidx.compose.animation.core.animate
4 | import androidx.compose.foundation.MutatorMutex
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.SideEffect
7 | import androidx.compose.runtime.State
8 | import androidx.compose.runtime.derivedStateOf
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.mutableStateOf
11 | import androidx.compose.runtime.remember
12 | import androidx.compose.runtime.rememberCoroutineScope
13 | import androidx.compose.runtime.rememberUpdatedState
14 | import androidx.compose.runtime.setValue
15 | import androidx.compose.ui.platform.LocalDensity
16 | import androidx.compose.ui.unit.Dp
17 | import androidx.compose.ui.unit.dp
18 | import kotlinx.coroutines.CoroutineScope
19 | import kotlinx.coroutines.launch
20 | import kotlin.math.abs
21 | import kotlin.math.pow
22 |
23 | /**
24 | * Creates a [PullRefreshState] that is remembered across compositions.
25 | *
26 | * Changes to [refreshing] will result in [PullRefreshState] being updated.
27 | *
28 | * @sample androidx.compose.material.samples.PullRefreshSample
29 | *
30 | * @param refreshing A boolean representing whether a refresh is currently occurring.
31 | * @param onRefresh The function to be called to trigger a refresh.
32 | * @param refreshThreshold The threshold below which, if a release
33 | * occurs, [onRefresh] will be called.
34 | * @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This
35 | * offset corresponds to the position of the bottom of the indicator.
36 | */
37 | @Composable
38 | fun rememberPullRefreshState(
39 | refreshing: Boolean,
40 | onRefresh: () -> Unit,
41 | refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
42 | refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
43 | ): PullRefreshState {
44 | require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
45 |
46 | val scope = rememberCoroutineScope()
47 | val onRefreshState = rememberUpdatedState(onRefresh)
48 | val thresholdPx: Float
49 | val refreshingOffsetPx: Float
50 |
51 | with(LocalDensity.current) {
52 | thresholdPx = refreshThreshold.toPx()
53 | refreshingOffsetPx = refreshingOffset.toPx()
54 | }
55 |
56 | val state = remember(scope) {
57 | PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx)
58 | }
59 |
60 | SideEffect {
61 | state.setRefreshing(refreshing)
62 | state.setThreshold(thresholdPx)
63 | state.setRefreshingOffset(refreshingOffsetPx)
64 | }
65 |
66 | return state
67 | }
68 |
69 | /**
70 | * A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh
71 | * behaviour to a scroll component. Based on Android's SwipeRefreshLayout.
72 | *
73 | * Provides [progress], a float representing how far the user has pulled as a percentage of the
74 | * refreshThreshold. Values of one or less indicate that the user has not yet pulled past the
75 | * threshold. Values greater than one indicate how far past the threshold the user has pulled.
76 | *
77 | * Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like
78 | * pull-to-refresh behaviour with a custom indicator.
79 | *
80 | * Should be created using [rememberPullRefreshState].
81 | */
82 | class PullRefreshState internal constructor(
83 | private val animationScope: CoroutineScope,
84 | private val onRefreshState: State<() -> Unit>,
85 | refreshingOffset: Float,
86 | threshold: Float,
87 | ) {
88 | /**
89 | * A float representing how far the user has pulled as a percentage of the refreshThreshold.
90 | *
91 | * If the component has not been pulled at all, progress is zero. If the pull has reached
92 | * halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has
93 | * gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to
94 | * two times the refreshThreshold.
95 | */
96 | val progress get() = adjustedDistancePulled / threshold
97 |
98 | internal val refreshing get() = _refreshing
99 | internal val position get() = _position
100 | internal val threshold get() = _threshold
101 |
102 | private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier }
103 |
104 | private var _refreshing by mutableStateOf(false)
105 | private var _position by mutableStateOf(0f)
106 | private var distancePulled by mutableStateOf(0f)
107 | private var _threshold by mutableStateOf(threshold)
108 | private var _refreshingOffset by mutableStateOf(refreshingOffset)
109 |
110 | internal fun onPull(pullDelta: Float): Float {
111 | if (_refreshing) return 0f // Already refreshing, do nothing.
112 |
113 | val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
114 | val dragConsumed = newOffset - distancePulled
115 | distancePulled = newOffset
116 | _position = calculateIndicatorPosition()
117 | return dragConsumed
118 | }
119 |
120 | internal fun onRelease(velocity: Float): Float {
121 | if (refreshing) return 0f // Already refreshing, do nothing
122 |
123 | if (adjustedDistancePulled > threshold) {
124 | onRefreshState.value()
125 | }
126 | animateIndicatorTo(0f)
127 | val consumed = when {
128 | // We are flinging without having dragged the pull refresh (for example a fling inside
129 | // a list) - don't consume
130 | distancePulled == 0f -> 0f
131 | // If the velocity is negative, the fling is upwards, and we don't want to prevent the
132 | // the list from scrolling
133 | velocity < 0f -> 0f
134 | // We are showing the indicator, and the fling is downwards - consume everything
135 | else -> velocity
136 | }
137 | distancePulled = 0f
138 | return consumed
139 | }
140 |
141 | internal fun setRefreshing(refreshing: Boolean) {
142 | if (_refreshing != refreshing) {
143 | _refreshing = refreshing
144 | distancePulled = 0f
145 | animateIndicatorTo(if (refreshing) _refreshingOffset else 0f)
146 | }
147 | }
148 |
149 | internal fun setThreshold(threshold: Float) {
150 | _threshold = threshold
151 | }
152 |
153 | internal fun setRefreshingOffset(refreshingOffset: Float) {
154 | if (_refreshingOffset != refreshingOffset) {
155 | _refreshingOffset = refreshingOffset
156 | if (refreshing) animateIndicatorTo(refreshingOffset)
157 | }
158 | }
159 |
160 | // Make sure to cancel any existing animations when we launch a new one. We use this instead of
161 | // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
162 | // overhead of running through the animation pipeline instead of directly mutating the state.
163 | private val mutatorMutex = MutatorMutex()
164 |
165 | private fun animateIndicatorTo(offset: Float) = animationScope.launch {
166 | mutatorMutex.mutate {
167 | animate(initialValue = _position, targetValue = offset) { value, _ ->
168 | _position = value
169 | }
170 | }
171 | }
172 |
173 | private fun calculateIndicatorPosition(): Float = when {
174 | // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
175 | adjustedDistancePulled <= threshold -> adjustedDistancePulled
176 | else -> {
177 | // How far beyond the threshold pull has gone, as a percentage of the threshold.
178 | val overshootPercent = abs(progress) - 1.0f
179 | // Limit the overshoot to 200%. Linear between 0 and 200.
180 | val linearTension = overshootPercent.coerceIn(0f, 2f)
181 | // Non-linear tension. Increases with linearTension, but at a decreasing rate.
182 | val tensionPercent = linearTension - linearTension.pow(2) / 4
183 | // The additional offset beyond the threshold.
184 | val extraOffset = threshold * tensionPercent
185 | threshold + extraOffset
186 | }
187 | }
188 | }
189 |
190 | /**
191 | * Default parameter values for [rememberPullRefreshState].
192 | */
193 | object PullRefreshDefaults {
194 | /**
195 | * If the indicator is below this threshold offset when it is released, a refresh
196 | * will be triggered.
197 | */
198 | val RefreshThreshold = 80.dp
199 |
200 | /**
201 | * The offset at which the indicator should be rendered whilst a refresh is occurring.
202 | */
203 | val RefreshingOffset = 56.dp
204 | }
205 |
206 | /**
207 | * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which
208 | * is used in calculating the indicator position (when the adjusted distance pulled is less than
209 | * the refresh threshold, it is the indicator position, otherwise the indicator position is
210 | * derived from the progress).
211 | */
212 | private const val DragMultiplier = 0.5f
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
87 |
88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
147 | # shellcheck disable=SC3045
148 | MAX_FD=$( ulimit -H -n ) ||
149 | warn "Could not query maximum file descriptor limit"
150 | esac
151 | case $MAX_FD in #(
152 | '' | soft) :;; #(
153 | *)
154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
155 | # shellcheck disable=SC3045
156 | ulimit -n "$MAX_FD" ||
157 | warn "Could not set maximum file descriptor limit to $MAX_FD"
158 | esac
159 | fi
160 |
161 | # Collect all arguments for the java command, stacking in reverse order:
162 | # * args from the command line
163 | # * the main class name
164 | # * -classpath
165 | # * -D...appname settings
166 | # * --module-path (only if needed)
167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
168 |
169 | # For Cygwin or MSYS, switch paths to Windows format before running java
170 | if "$cygwin" || "$msys" ; then
171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
173 |
174 | JAVACMD=$( cygpath --unix "$JAVACMD" )
175 |
176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
177 | for arg do
178 | if
179 | case $arg in #(
180 | -*) false ;; # don't mess with options #(
181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
182 | [ -e "$t" ] ;; #(
183 | *) false ;;
184 | esac
185 | then
186 | arg=$( cygpath --path --ignore --mixed "$arg" )
187 | fi
188 | # Roll the args list around exactly as many times as the number of
189 | # args, so each arg winds up back in the position where it started, but
190 | # possibly modified.
191 | #
192 | # NB: a `for` loop captures its iteration list before it begins, so
193 | # changing the positional parameters here affects neither the number of
194 | # iterations, nor the values presented in `arg`.
195 | shift # remove old arg
196 | set -- "$@" "$arg" # push replacement arg
197 | done
198 | fi
199 |
200 | # Collect all arguments for the java command;
201 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
202 | # shell script including quotes and variable substitutions, so put them in
203 | # double quotes to make sure that they get re-expanded; and
204 | # * put everything else in single quotes, so that it's not re-expanded.
205 |
206 | set -- \
207 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
208 | -classpath "$CLASSPATH" \
209 | org.gradle.wrapper.GradleWrapperMain \
210 | "$@"
211 |
212 | # Stop when "xargs" is not available.
213 | if ! command -v xargs >/dev/null 2>&1
214 | then
215 | die "xargs is not available"
216 | fi
217 |
218 | # Use "xargs" to parse quoted args.
219 | #
220 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
221 | #
222 | # In Bash we could simply go:
223 | #
224 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
225 | # set -- "${ARGS[@]}" "$@"
226 | #
227 | # but POSIX shell has neither arrays nor command substitution, so instead we
228 | # post-process each arg (as a line of input to sed) to backslash-escape any
229 | # character that might be a shell metacharacter, then use eval to reverse
230 | # that process (while maintaining the separation between arguments), and wrap
231 | # the whole thing up as a single "set" statement.
232 | #
233 | # This will of course break if any of these variables contains a newline or
234 | # an unmatched quote.
235 | #
236 |
237 | eval "set -- $(
238 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
239 | xargs -n1 |
240 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
241 | tr '\n' ' '
242 | )" '"$@"'
243 |
244 | exec "$JAVACMD" "$@"
245 |
--------------------------------------------------------------------------------