├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── androidTestLib ├── .gitignore ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── it │ ├── codingjam │ └── github │ │ └── espresso │ │ ├── FragmentTestRule.kt │ │ ├── MockTestRunner.kt │ │ ├── SingleFragmentActivity.kt │ │ └── TestApplication.kt │ └── cosenonjaviste │ └── daggermock │ └── DaggerMockKtx.kt ├── api ├── .gitignore ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── it │ │ └── codingjam │ │ └── github │ │ └── api │ │ ├── ApiModule.kt │ │ ├── GithubRepositoryImpl.kt │ │ ├── GithubService.kt │ │ └── util │ │ ├── DenvelopingConverter.kt │ │ ├── EnvelopePayload.kt │ │ └── RetrofitFactory.kt │ └── test │ ├── java │ └── it │ │ └── codingjam │ │ └── github │ │ └── api │ │ ├── GithubRepositoryTest.kt │ │ └── GithubServiceTest.kt │ └── resources │ └── api-response │ ├── contributors.json │ ├── repos-yigit.json │ ├── search.json │ └── user-yigit.json ├── app ├── .gitignore ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── it │ │ └── codingjam │ │ └── github │ │ ├── AndroidNavigationController.kt │ │ ├── GithubApp.kt │ │ ├── MainActivity.kt │ │ └── di │ │ └── AppModule.kt │ └── res │ ├── layout │ └── main_activity.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── navigation │ └── nav_graph.xml │ └── values │ ├── colors.xml │ └── styles.xml ├── build.gradle ├── core ├── .gitignore ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── it │ │ └── codingjam │ │ └── github │ │ └── core │ │ ├── AllOpen.kt │ │ ├── Contributor.kt │ │ ├── CoreComponent.kt │ │ ├── GithubInteractor.kt │ │ ├── GithubRepository.kt │ │ ├── OpenForTesting.kt │ │ ├── Owner.kt │ │ ├── Repo.kt │ │ ├── RepoDetail.kt │ │ ├── RepoId.kt │ │ ├── RepoSearchResponse.kt │ │ ├── User.kt │ │ ├── UserDetail.kt │ │ └── utils │ │ ├── ComponentHolder.kt │ │ └── deepCopy.kt │ └── test │ └── java │ └── it │ └── codingjam │ └── github │ └── core │ └── GithubInteractorTest.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle ├── testData ├── .gitignore ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── it │ └── codingjam │ └── github │ └── testdata │ ├── CustomAssertions.kt │ ├── TestAppModule.kt │ └── TestData.kt ├── uirepo ├── .gitignore ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── it │ │ │ └── codingjam │ │ │ └── github │ │ │ └── ui │ │ │ └── repo │ │ │ ├── ContributorViewHolder.kt │ │ │ ├── RepoFragment.kt │ │ │ ├── RepoModule.kt │ │ │ ├── RepoUseCase.kt │ │ │ ├── RepoViewModel.kt │ │ │ └── RepoViewState.kt │ └── res │ │ ├── layout │ │ ├── contributor_item.xml │ │ └── repo_fragment.xml │ │ └── navigation │ │ └── repo_nav_graph.xml │ └── test │ └── java │ └── it │ └── codingjam │ └── github │ └── ui │ └── repo │ └── RepoUseCaseTest.kt ├── uirepoTest ├── .gitignore ├── build.gradle └── src │ ├── androidTest │ └── java │ │ └── it │ │ └── codingjam │ │ └── github │ │ └── ui │ │ └── repo │ │ └── RepoFragmentTest.kt │ └── main │ └── AndroidManifest.xml ├── uisearch ├── .gitignore ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── it │ │ │ └── codingjam │ │ │ └── github │ │ │ └── ui │ │ │ └── search │ │ │ ├── RepoViewHolder.kt │ │ │ ├── SearchFragment.kt │ │ │ ├── SearchModule.kt │ │ │ ├── SearchUseCase.kt │ │ │ ├── SearchViewModel.kt │ │ │ └── SearchViewState.kt │ └── res │ │ ├── layout │ │ ├── search_fragment.xml │ │ └── search_result.xml │ │ └── navigation │ │ └── search_nav_graph.xml │ └── test │ └── java │ └── it │ └── codingjam │ └── github │ └── ui │ └── search │ └── SearchUseCaseTest.kt ├── uisearchTest ├── .gitignore ├── build.gradle └── src │ ├── androidTest │ └── java │ │ └── it │ │ └── codingjam │ │ └── github │ │ └── ui │ │ └── repo │ │ ├── SearchFragmentTest.kt │ │ └── SearchFragmentViewModelTest.kt │ └── main │ └── AndroidManifest.xml ├── uiuser ├── .gitignore ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── it │ │ │ └── codingjam │ │ │ └── github │ │ │ └── ui │ │ │ └── user │ │ │ ├── UserFragment.kt │ │ │ ├── UserModule.kt │ │ │ ├── UserRepoViewHolder.kt │ │ │ ├── UserUseCase.kt │ │ │ ├── UserViewModel.kt │ │ │ └── UserViewState.kt │ └── res │ │ ├── layout │ │ └── user_fragment.xml │ │ └── navigation │ │ └── user_nav_graph.xml │ └── test │ └── java │ └── it │ └── codingjam │ └── github │ └── ui │ └── user │ └── UserUseCaseTest.kt ├── uiuserTest ├── .gitignore ├── build.gradle └── src │ ├── androidTest │ └── java │ │ └── it │ │ └── codingjam │ │ └── github │ │ └── ui │ │ └── user │ │ └── UserFragmentTest.kt │ └── main │ └── AndroidManifest.xml └── viewlib ├── .gitignore ├── build.gradle └── src └── main ├── AndroidManifest.xml ├── java └── it │ └── codingjam │ └── github │ ├── ComponentHolderKtx.kt │ ├── FeatureAppScope.kt │ ├── NavigationController.kt │ ├── ViewLibModule.kt │ ├── binding │ └── BindingAdapters.kt │ ├── ui │ └── common │ │ ├── DataBoundListAdapter.kt │ │ ├── DataBoundViewHolder.kt │ │ └── FragmentCreator.kt │ ├── util │ ├── Action.kt │ ├── ErrorSignal.kt │ ├── EventsLiveData.kt │ ├── LceContainer.kt │ ├── NavigationSignal.kt │ ├── ViewModelUtils.kt │ └── ViewStateStore.kt │ └── vo │ └── Lce.kt └── res ├── layout ├── error.xml ├── loading.xml └── repo_item.xml └── values ├── dimens.xml └── strings.xml /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .idea 3 | .gradle 4 | /local.properties 5 | .DS_Store 6 | build/ 7 | /captures 8 | .externalNativeBuild 9 | app/build 10 | build 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: android 2 | jdk: 3 | - oraclejdk8 4 | 5 | sudo: false 6 | 7 | cache: 8 | directories: 9 | - $HOME/.gradle/caches/ 10 | 11 | env: 12 | global: 13 | MALLOC_ARENA_MAX=2 14 | 15 | android: 16 | components: 17 | - tools 18 | - platform-tools 19 | - build-tools-26.0.3 20 | - android-27 21 | - extra-google-m2repository 22 | - extra-android-m2repository 23 | 24 | before_install: 25 | # Install SDK license so Android Gradle plugin can install deps. 26 | - mkdir "$ANDROID_HOME/licenses" || true 27 | - echo "d56f5187479451eabf01fb78af6dfcb131a6481e" > "$ANDROID_HOME/licenses/android-sdk-license" 28 | 29 | before_script: 30 | - export "JAVA_OPTS=-Xmx1024m" 31 | - export "JAVA7_HOME=/usr/lib/jvm/java-7-oracle" 32 | - export "JAVA8_HOME=/usr/lib/jvm/java-8-oracle" 33 | 34 | script: 35 | - ./gradlew :app:assembleDebug testDebugUnitTest --stacktrace 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Android Architecture Components Demo 2 | 3 | This is a Kotlin project that uses some Android Architecture Components (ViewModel and LiveData) with Dagger 2 and RxJava. 4 | 5 | This project is a fork of official [GithubBrowserSample](https://github.com/googlesamples/android-architecture-components/tree/master/GithubBrowserSample), 6 | I converted it to kotlin and modified some architectural aspects to test ViewModel and LiveData features. 7 | 8 | ## Main concepts 9 | * each ViewModel manages an immutable ViewState (implemented using a Kotlin data object) 10 | * a post will be available soon to explain the details of how the ViewModels and the UseCases manage the ViewStates 11 | * multi module project, thanks to Dagger Android the ui is splitted in multiple modules 12 | * Fragment args are managed using a [companion object base class](https://github.com/fabioCollini/ArchitectureComponentsDemo/blob/master/viewlib/src/main/java/it/codingjam/github/ui/common/FragmentCreator.kt) 13 | * JVM tests are written using Mockito and [AssertK](https://github.com/willowtreeapps/assertk) 14 | * Espresso tests are written using [DaggerMock](https://github.com/fabioCollini/DaggerMock) and Mockito 15 | * ViewModels are instantiated using a [custom factory](https://github.com/fabioCollini/ArchitectureComponentsDemo/blob/master/viewlib/src/main/java/it/codingjam/github/util/ViewModelFactory.kt) defined in Dagger application scope and replaced with a stub in Espresso tests 16 | * asynchronous tasks are managed using [Coroutines](https://github.com/Kotlin/kotlinx.coroutines/blob/master/coroutines-guide.md) (the [rxjava branch](https://github.com/fabioCollini/ArchitectureComponentsDemo/tree/rxjava) contains the same example with RxJava singles instead of coroutines) 17 | -------------------------------------------------------------------------------- /androidTestLib/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /androidTestLib/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: "kotlin-allopen" 5 | 6 | allOpen { 7 | annotation("it.codingjam.github.core.AllOpen") 8 | } 9 | 10 | android { 11 | compileSdkVersion 28 12 | 13 | defaultConfig { 14 | minSdkVersion 16 15 | targetSdkVersion 28 16 | versionCode 1 17 | versionName "1.0" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | } 27 | 28 | dependencies { 29 | api project(':core') 30 | api project(':viewlib') 31 | 32 | implementation "com.jakewharton.timber:timber:$timber_version" 33 | 34 | implementation "com.google.dagger:dagger:$dagger_version" 35 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 36 | 37 | api "androidx.legacy:legacy-support-v4:$support_version" 38 | api "com.google.android.material:material:$support_version" 39 | 40 | api "androidx.test:rules:1.1.1" 41 | api "androidx.test:core:1.1.0" 42 | api("androidx.test.espresso:espresso-core:$espresso_version", { 43 | exclude group: 'com.android.support', module: 'support-annotations' 44 | exclude group: 'com.google.code.findbugs', module: 'jsr305' 45 | }) 46 | api("androidx.test.espresso:espresso-contrib:$espresso_version", { 47 | exclude group: 'com.android.support', module: 'support-annotations' 48 | exclude group: 'com.google.code.findbugs', module: 'jsr305' 49 | }) 50 | 51 | api "androidx.arch.core:core-testing:2.0.1" 52 | api "org.mockito:mockito-android:$mockito_version" 53 | api('com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0') { 54 | exclude group: 'org.jetbrains.kotlin' 55 | } 56 | api 'com.github.fabioCollini.daggermock:daggermock:0.8.4' 57 | api('com.github.fabioCollini.daggermock:daggermock-kotlin:0.8.4') { 58 | exclude group: 'org.jetbrains.kotlin' 59 | } 60 | 61 | implementation "android.arch.navigation:navigation-fragment-ktx:$nav_version" 62 | implementation "android.arch.navigation:navigation-ui-ktx:$nav_version" 63 | } 64 | -------------------------------------------------------------------------------- /androidTestLib/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /androidTestLib/src/main/java/it/codingjam/github/espresso/FragmentTestRule.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.espresso 2 | 3 | 4 | import android.os.Bundle 5 | import androidx.test.rule.ActivityTestRule 6 | import it.codingjam.github.ui.common.FragmentCreator 7 | import it.codingjam.github.ui.common.args 8 | 9 | class FragmentTestRule( 10 | private val graphId: Int, 11 | private val nodeId: Int, 12 | private val bundleCreator: (T) -> Bundle 13 | ) : ActivityTestRule(SingleFragmentActivity::class.java, false, false) { 14 | 15 | fun launchFragment(param: T) { 16 | launchActivity(null) 17 | activity.setFragment(graphId, nodeId, bundleCreator(param)) 18 | } 19 | } 20 | 21 | fun FragmentCreator.rule() = 22 | FragmentTestRule(graphId, nodeId) { args(it) } 23 | -------------------------------------------------------------------------------- /androidTestLib/src/main/java/it/codingjam/github/espresso/MockTestRunner.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.espresso 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.test.runner.AndroidJUnitRunner 6 | 7 | class MockTestRunner: AndroidJUnitRunner() { 8 | override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { 9 | return super.newApplication(cl, TestApplication::class.java.name, context) 10 | } 11 | } -------------------------------------------------------------------------------- /androidTestLib/src/main/java/it/codingjam/github/espresso/SingleFragmentActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.espresso 18 | 19 | import android.os.Bundle 20 | import android.view.ViewGroup.LayoutParams.MATCH_PARENT 21 | import android.widget.FrameLayout 22 | import android.widget.FrameLayout.LayoutParams 23 | import androidx.appcompat.app.AppCompatActivity 24 | import androidx.navigation.NavController 25 | import androidx.navigation.fragment.FragmentNavigator 26 | import androidx.navigation.get 27 | 28 | class SingleFragmentActivity : AppCompatActivity() { 29 | 30 | override fun onCreate(savedInstanceState: Bundle?) { 31 | super.onCreate(savedInstanceState) 32 | val content = FrameLayout(this) 33 | content.layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) 34 | content.id = R.id.container 35 | setContentView(content) 36 | } 37 | 38 | fun setFragment(graphId: Int, nodeId: Int, args: Bundle) { 39 | val navController = NavController(this) 40 | navController.navigatorProvider.addNavigator(FragmentNavigator(this, supportFragmentManager, 123)) 41 | val navGraph = navController.navInflater.inflate(graphId) 42 | val node = navGraph[nodeId] 43 | val fragmentClass = (node as FragmentNavigator.Destination).className 44 | 45 | val fragment = androidx.fragment.app.Fragment.instantiate(this, fragmentClass).apply { 46 | arguments = args 47 | } 48 | 49 | supportFragmentManager.beginTransaction() 50 | .add(R.id.container, fragment, "TEST") 51 | .commit() 52 | 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /androidTestLib/src/main/java/it/codingjam/github/espresso/TestApplication.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.espresso 2 | 3 | import android.app.Application 4 | import it.codingjam.github.core.utils.ComponentHolder 5 | import it.codingjam.github.core.utils.ComponentsMap 6 | 7 | class TestApplication : Application(), ComponentHolder by ComponentsMap() -------------------------------------------------------------------------------- /androidTestLib/src/main/java/it/cosenonjaviste/daggermock/DaggerMockKtx.kt: -------------------------------------------------------------------------------- 1 | package it.cosenonjaviste.daggermock 2 | 3 | import org.mockito.Mockito 4 | import org.mockito.stubbing.Answer 5 | 6 | fun DaggerMock.Companion.overrideComponent(c: Class<*>, componentFactory: () -> Any, testObj: Any): Any { 7 | val overriddenObjectsMap = OverriddenObjectsMap() 8 | overriddenObjectsMap.init(testObj) 9 | 10 | val component = try { 11 | componentFactory() 12 | } catch (e: Exception) { 13 | null 14 | } 15 | 16 | val defaultAnswer = Answer { invocation -> 17 | val method = invocation.method 18 | val provider = overriddenObjectsMap.getProvider(method) 19 | if (provider != null) { 20 | provider.get() 21 | } else { 22 | if (component != null) { 23 | method.isAccessible = true 24 | method.invoke(component, *invocation.arguments) 25 | } else { 26 | Mockito.mock(method.returnType) 27 | } 28 | } 29 | } 30 | return Mockito.mock(c, defaultAnswer) 31 | } 32 | 33 | fun DaggerMock.Companion.interceptor(testObj: Any): (Class<*>, () -> Any) -> Any { 34 | return { c, componentFactory -> 35 | DaggerMock.overrideComponent(c, componentFactory, testObj) 36 | } 37 | } -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /api/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | 5 | android { 6 | compileSdkVersion 28 7 | 8 | defaultConfig { 9 | minSdkVersion 16 10 | targetSdkVersion 28 11 | versionCode 1 12 | versionName "1.0" 13 | } 14 | 15 | buildTypes { 16 | release { 17 | minifyEnabled false 18 | } 19 | } 20 | } 21 | 22 | dependencies { 23 | implementation project(':core') 24 | 25 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 26 | 27 | implementation "com.squareup.retrofit2:retrofit:$retrofit_version" 28 | implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" 29 | implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" 30 | implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2' 31 | 32 | testImplementation project(':testData') 33 | testImplementation "com.squareup.retrofit2:retrofit-mock:$retrofit_version" 34 | testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version" 35 | } 36 | -------------------------------------------------------------------------------- /api/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /api/src/main/java/it/codingjam/github/api/ApiModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.api 18 | 19 | import dagger.Component 20 | import dagger.Module 21 | import dagger.Provides 22 | import it.codingjam.github.api.util.RetrofitFactory.createService 23 | import it.codingjam.github.core.CoreDependencies 24 | import it.codingjam.github.core.GithubRepository 25 | import okhttp3.HttpUrl 26 | import javax.inject.Singleton 27 | 28 | @Module 29 | class ApiModule { 30 | @Singleton @Provides fun provideGithubService(): GithubService = 31 | createService(BuildConfig.DEBUG, HttpUrl.parse("https://api.github.com/")!!) 32 | 33 | @Provides @Singleton fun githubRepository(githubService: GithubService): GithubRepository = GithubRepositoryImpl(githubService) 34 | } 35 | 36 | @Singleton 37 | @Component( 38 | modules = [ApiModule::class] 39 | ) 40 | interface ApiComponent : CoreDependencies -------------------------------------------------------------------------------- /api/src/main/java/it/codingjam/github/api/GithubRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.api 18 | 19 | import it.codingjam.github.core.GithubRepository 20 | import it.codingjam.github.core.Repo 21 | import it.codingjam.github.core.RepoSearchResponse 22 | import it.codingjam.github.core.User 23 | import retrofit2.HttpException 24 | import retrofit2.Response 25 | import java.util.regex.Pattern 26 | 27 | class GithubRepositoryImpl(private val githubService: GithubService) : GithubRepository { 28 | 29 | override suspend fun getRepo(owner: String, name: String) = githubService.getRepo(owner, name).await() 30 | 31 | override suspend fun getContributors(owner: String, name: String) = githubService.getContributors(owner, name).await() 32 | 33 | override suspend fun loadRepos(owner: String): List = githubService.getRepos(owner).await() 34 | 35 | override suspend fun searchNextPage(query: String, nextPage: Int): RepoSearchResponse = 36 | githubService.searchRepos(query, nextPage).await().let { toRepoSearchResponse(it) } 37 | 38 | override suspend fun search(query: String): RepoSearchResponse = 39 | githubService.searchRepos(query).await().let { toRepoSearchResponse(it) } 40 | 41 | private fun toRepoSearchResponse(response: Response>): RepoSearchResponse { 42 | if (response.isSuccessful) { 43 | return RepoSearchResponse(response.body() 44 | ?: emptyList(), extractNextPage(response)) 45 | } else { 46 | throw HttpException(response) 47 | } 48 | } 49 | 50 | private fun extractNextPage(response: Response>): Int? { 51 | response.headers().get("link")?.let { 52 | val matcher = LINK_PATTERN.matcher(it) 53 | 54 | while (matcher.find()) { 55 | val count = matcher.groupCount() 56 | if (count == 2) { 57 | if (matcher.group(2) == NEXT_LINK) { 58 | val next = matcher.group(1) 59 | val pageMatcher = PAGE_PATTERN.matcher(next) 60 | if (!pageMatcher.find() || pageMatcher.groupCount() != 1) { 61 | return null 62 | } 63 | return try { 64 | Integer.parseInt(pageMatcher.group(1)) 65 | } catch (ex: NumberFormatException) { 66 | null 67 | } 68 | 69 | } 70 | } 71 | } 72 | } 73 | return null 74 | } 75 | 76 | override suspend fun loadUser(login: String): User = githubService.getUser(login).await() 77 | 78 | companion object { 79 | 80 | private val LINK_PATTERN = Pattern 81 | .compile("<([^>]*)>[\\s]*;[\\s]*rel=\"([a-zA-Z0-9]+)\"") 82 | private val PAGE_PATTERN = Pattern.compile("page=(\\d)+") 83 | private const val NEXT_LINK = "next" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /api/src/main/java/it/codingjam/github/api/GithubService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.api 18 | 19 | import it.codingjam.github.api.util.EnvelopePayload 20 | import it.codingjam.github.core.Contributor 21 | import it.codingjam.github.core.Repo 22 | import it.codingjam.github.core.User 23 | import kotlinx.coroutines.Deferred 24 | import retrofit2.Response 25 | import retrofit2.http.GET 26 | import retrofit2.http.Path 27 | import retrofit2.http.Query 28 | 29 | interface GithubService { 30 | @GET("users/{login}") 31 | fun getUser(@Path("login") login: String): Deferred 32 | 33 | @GET("users/{login}/repos") 34 | fun getRepos(@Path("login") login: String): Deferred> 35 | 36 | @GET("repos/{owner}/{name}") 37 | fun getRepo(@Path("owner") owner: String, @Path("name") name: String): Deferred 38 | 39 | @GET("repos/{owner}/{name}/contributors") 40 | fun getContributors(@Path("owner") owner: String, @Path("name") name: String): Deferred> 41 | 42 | @EnvelopePayload("items") 43 | @GET("search/repositories") 44 | fun searchRepos(@Query("q") query: String): Deferred>> 45 | 46 | @EnvelopePayload("items") 47 | @GET("search/repositories") 48 | fun searchRepos(@Query("q") query: String, @Query("page") page: Int): Deferred>> 49 | } 50 | -------------------------------------------------------------------------------- /api/src/main/java/it/codingjam/github/api/util/DenvelopingConverter.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.api.util 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.reflect.TypeToken 5 | import okhttp3.ResponseBody 6 | import retrofit2.Converter 7 | import retrofit2.Retrofit 8 | import java.lang.reflect.Type 9 | 10 | /** 11 | * A [Converter.Factory] which removes unwanted wrapping envelopes from API 12 | * responses. 13 | */ 14 | class DenvelopingConverter(internal val gson: Gson) : Converter.Factory() { 15 | 16 | override fun responseBodyConverter( 17 | type: Type, annotations: Array?, retrofit: Retrofit?): Converter? { 18 | 19 | // This converter requires an annotation providing the name of the payload in the envelope; 20 | // if one is not supplied then return null to continue down the converter chain. 21 | val payloadName = getPayloadName(annotations) ?: return null 22 | 23 | val adapter = gson.getAdapter(TypeToken.get(type)) 24 | return Converter { body -> 25 | try { 26 | val jsonReader = gson.newJsonReader(body.charStream()) 27 | jsonReader.beginObject() 28 | while (jsonReader.hasNext()) { 29 | if (payloadName == jsonReader.nextName()) { 30 | return@Converter adapter.read(jsonReader) 31 | } else { 32 | jsonReader.skipValue() 33 | } 34 | } 35 | return@Converter null 36 | } finally { 37 | body.close() 38 | } 39 | } 40 | } 41 | 42 | private fun getPayloadName(annotations: Array?): String? { 43 | if (annotations == null) { 44 | return null 45 | } 46 | for (annotation in annotations) { 47 | if (annotation is EnvelopePayload) { 48 | return annotation.value 49 | } 50 | } 51 | return null 52 | } 53 | } -------------------------------------------------------------------------------- /api/src/main/java/it/codingjam/github/api/util/EnvelopePayload.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.api.util 2 | 3 | 4 | /** 5 | * An annotation for identifying the payload that we want to extract from an API response wrapped in 6 | * an envelope object. 7 | */ 8 | @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) 9 | @Retention(AnnotationRetention.RUNTIME) 10 | annotation class EnvelopePayload(val value: String = "") -------------------------------------------------------------------------------- /api/src/main/java/it/codingjam/github/api/util/RetrofitFactory.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.api.util 2 | 3 | import com.google.gson.GsonBuilder 4 | import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory 5 | import okhttp3.HttpUrl 6 | import okhttp3.OkHttpClient 7 | import okhttp3.logging.HttpLoggingInterceptor 8 | import retrofit2.Retrofit 9 | import retrofit2.converter.gson.GsonConverterFactory 10 | 11 | object RetrofitFactory { 12 | inline fun createService(debug: Boolean, baseUrl: HttpUrl): T { 13 | val httpClient = OkHttpClient.Builder() 14 | 15 | if (debug) { 16 | val logging = HttpLoggingInterceptor() 17 | logging.level = HttpLoggingInterceptor.Level.BODY 18 | httpClient.addInterceptor(logging) 19 | } 20 | 21 | val gson = GsonBuilder().create() 22 | 23 | return Retrofit.Builder() 24 | .baseUrl(baseUrl) 25 | .addConverterFactory(DenvelopingConverter(gson)) 26 | .addConverterFactory(GsonConverterFactory.create(gson)) 27 | .addCallAdapterFactory(CoroutineCallAdapterFactory()) 28 | .client(httpClient.build()) 29 | .build() 30 | .create(T::class.java) 31 | } 32 | } -------------------------------------------------------------------------------- /api/src/test/java/it/codingjam/github/api/GithubRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.api 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.containsExactly 5 | import assertk.assertions.isEqualTo 6 | import com.nhaarman.mockitokotlin2.mock 7 | import com.nhaarman.mockitokotlin2.whenever 8 | import it.codingjam.github.core.Owner 9 | import it.codingjam.github.core.Repo 10 | import kotlinx.coroutines.async 11 | import kotlinx.coroutines.runBlocking 12 | import okhttp3.Headers 13 | import org.junit.Test 14 | import retrofit2.Response 15 | 16 | class GithubRepositoryTest { 17 | 18 | val githubService: GithubService = mock() 19 | 20 | val repository = GithubRepositoryImpl(githubService) 21 | 22 | @Test 23 | fun search() = runBlocking { 24 | val header = "; rel=\"next\"," + 25 | " ; rel=\"last\"" 26 | val headers = mapOf("link" to header) 27 | 28 | whenever(githubService.searchRepos(QUERY)).thenReturn( 29 | async { Response.success(listOf(REPO_1, REPO_2), Headers.of(headers)) }) 30 | 31 | val (items, nextPage) = repository.search(QUERY) 32 | 33 | assertThat(items).containsExactly(REPO_1, REPO_2) 34 | assertThat(nextPage).isEqualTo(2) 35 | } 36 | 37 | companion object { 38 | private const val QUERY = "abc" 39 | val OWNER = Owner("login", "url") 40 | val REPO_1 = Repo(1, "name", "fullName", "desc", OWNER, 10000) 41 | val REPO_2 = Repo(2, "name", "fullName", "desc", OWNER, 10000) 42 | } 43 | } -------------------------------------------------------------------------------- /api/src/test/java/it/codingjam/github/api/GithubServiceTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.api 18 | 19 | import assertk.assertThat 20 | import assertk.assertions.isEqualTo 21 | import assertk.assertions.isNotNull 22 | import it.codingjam.github.api.util.RetrofitFactory 23 | import kotlinx.coroutines.runBlocking 24 | import okhttp3.mockwebserver.MockResponse 25 | import okhttp3.mockwebserver.MockWebServer 26 | import okio.Okio 27 | import org.junit.After 28 | import org.junit.Before 29 | import org.junit.Test 30 | import java.nio.charset.StandardCharsets 31 | 32 | class GithubServiceTest { 33 | 34 | lateinit var service: GithubService 35 | 36 | val mockWebServer = MockWebServer() 37 | 38 | @Before 39 | fun createService() { 40 | service = RetrofitFactory.createService(false, mockWebServer.url("/")) 41 | } 42 | 43 | @After 44 | fun stopService() { 45 | mockWebServer.shutdown() 46 | } 47 | 48 | @Test 49 | fun getUser() = runBlocking { 50 | enqueueResponse("user-yigit.json") 51 | val yigit = service.getUser("yigit").await() 52 | 53 | val request = mockWebServer.takeRequest() 54 | assertThat(request.path).isEqualTo("/users/yigit") 55 | 56 | assertThat(yigit).isNotNull() 57 | assertThat(yigit.avatarUrl).isEqualTo("https://avatars3.githubusercontent.com/u/89202?v=3") 58 | assertThat(yigit.company).isEqualTo("Google") 59 | assertThat(yigit.blog).isEqualTo("birbit.com") 60 | } 61 | 62 | @Test 63 | fun repos() = runBlocking { 64 | enqueueResponse("repos-yigit.json") 65 | val repos = service.getRepos("yigit").await() 66 | 67 | val request = mockWebServer.takeRequest() 68 | assertThat(request.path).isEqualTo("/users/yigit/repos") 69 | 70 | assertThat(repos.size).isEqualTo(2) 71 | 72 | val (_, _, fullName, _, owner) = repos[0] 73 | assertThat(fullName).isEqualTo("yigit/AckMate") 74 | 75 | assertThat(owner).isNotNull() 76 | assertThat(owner.login).isEqualTo("yigit") 77 | assertThat(owner.url).isEqualTo("https://api.github.com/users/yigit") 78 | 79 | assertThat(repos[1].fullName).isEqualTo("yigit/android-architecture") 80 | } 81 | 82 | @Test 83 | fun getContributors() = runBlocking { 84 | enqueueResponse("contributors.json") 85 | val contributors = service.getContributors("foo", "bar").await() 86 | assertThat(contributors.size).isEqualTo(3) 87 | val (login, contributions, avatarUrl) = contributors[0] 88 | assertThat(login).isEqualTo("yigit") 89 | assertThat(avatarUrl).isEqualTo("https://avatars3.githubusercontent.com/u/89202?v=3") 90 | assertThat(contributions).isEqualTo(291) 91 | assertThat(contributors[1].login).isEqualTo("guavabot") 92 | assertThat(contributors[2].login).isEqualTo("coltin") 93 | } 94 | 95 | @Test 96 | fun search() = runBlocking { 97 | val header = "; rel=\"next\"," + " ; rel=\"last\"" 98 | enqueueResponse("search.json", mapOf("link" to header)) 99 | val response = service.searchRepos("foo").await() 100 | 101 | assertThat(response).isNotNull() 102 | assertThat(response.body()?.size).isEqualTo(30) 103 | } 104 | 105 | private fun enqueueResponse(fileName: String, headers: Map = emptyMap()) { 106 | val inputStream = javaClass.classLoader 107 | .getResourceAsStream("api-response/" + fileName) 108 | val source = Okio.buffer(Okio.source(inputStream)) 109 | val mockResponse = MockResponse() 110 | headers.forEach { key, value -> mockResponse.addHeader(key, value) } 111 | mockWebServer.enqueue(mockResponse 112 | .setBody(source.readString(StandardCharsets.UTF_8))) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /api/src/test/resources/api-response/contributors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "login": "yigit", 4 | "id": 89202, 5 | "avatar_url": "https://avatars3.githubusercontent.com/u/89202?v=3", 6 | "gravatar_id": "", 7 | "url": "https://api.github.com/users/yigit", 8 | "html_url": "https://github.com/yigit", 9 | "followers_url": "https://api.github.com/users/yigit/followers", 10 | "following_url": "https://api.github.com/users/yigit/following{/other_user}", 11 | "gists_url": "https://api.github.com/users/yigit/gists{/gist_id}", 12 | "starred_url": "https://api.github.com/users/yigit/starred{/owner}{/repo}", 13 | "subscriptions_url": "https://api.github.com/users/yigit/subscriptions", 14 | "organizations_url": "https://api.github.com/users/yigit/orgs", 15 | "repos_url": "https://api.github.com/users/yigit/repos", 16 | "events_url": "https://api.github.com/users/yigit/events{/privacy}", 17 | "received_events_url": "https://api.github.com/users/yigit/received_events", 18 | "type": "User", 19 | "site_admin": false, 20 | "contributions": 291 21 | }, 22 | { 23 | "login": "guavabot", 24 | "id": 6390457, 25 | "avatar_url": "https://avatars3.githubusercontent.com/u/6390457?v=3", 26 | "gravatar_id": "", 27 | "url": "https://api.github.com/users/guavabot", 28 | "html_url": "https://github.com/guavabot", 29 | "followers_url": "https://api.github.com/users/guavabot/followers", 30 | "following_url": "https://api.github.com/users/guavabot/following{/other_user}", 31 | "gists_url": "https://api.github.com/users/guavabot/gists{/gist_id}", 32 | "starred_url": "https://api.github.com/users/guavabot/starred{/owner}{/repo}", 33 | "subscriptions_url": "https://api.github.com/users/guavabot/subscriptions", 34 | "organizations_url": "https://api.github.com/users/guavabot/orgs", 35 | "repos_url": "https://api.github.com/users/guavabot/repos", 36 | "events_url": "https://api.github.com/users/guavabot/events{/privacy}", 37 | "received_events_url": "https://api.github.com/users/guavabot/received_events", 38 | "type": "User", 39 | "site_admin": false, 40 | "contributions": 10 41 | }, 42 | { 43 | "login": "coltin", 44 | "id": 577097, 45 | "avatar_url": "https://avatars0.githubusercontent.com/u/577097?v=3", 46 | "gravatar_id": "", 47 | "url": "https://api.github.com/users/coltin", 48 | "html_url": "https://github.com/coltin", 49 | "followers_url": "https://api.github.com/users/coltin/followers", 50 | "following_url": "https://api.github.com/users/coltin/following{/other_user}", 51 | "gists_url": "https://api.github.com/users/coltin/gists{/gist_id}", 52 | "starred_url": "https://api.github.com/users/coltin/starred{/owner}{/repo}", 53 | "subscriptions_url": "https://api.github.com/users/coltin/subscriptions", 54 | "organizations_url": "https://api.github.com/users/coltin/orgs", 55 | "repos_url": "https://api.github.com/users/coltin/repos", 56 | "events_url": "https://api.github.com/users/coltin/events{/privacy}", 57 | "received_events_url": "https://api.github.com/users/coltin/received_events", 58 | "type": "User", 59 | "site_admin": false, 60 | "contributions": 6 61 | } 62 | ] -------------------------------------------------------------------------------- /api/src/test/resources/api-response/repos-yigit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 2116198, 4 | "name": "AckMate", 5 | "full_name": "yigit/AckMate", 6 | "owner": { 7 | "login": "yigit", 8 | "id": 89202, 9 | "avatar_url": "https://avatars3.githubusercontent.com/u/89202?v=3", 10 | "gravatar_id": "", 11 | "url": "https://api.github.com/users/yigit", 12 | "html_url": "https://github.com/yigit", 13 | "followers_url": "https://api.github.com/users/yigit/followers", 14 | "following_url": "https://api.github.com/users/yigit/following{/other_user}", 15 | "gists_url": "https://api.github.com/users/yigit/gists{/gist_id}", 16 | "starred_url": "https://api.github.com/users/yigit/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/yigit/subscriptions", 18 | "organizations_url": "https://api.github.com/users/yigit/orgs", 19 | "repos_url": "https://api.github.com/users/yigit/repos", 20 | "events_url": "https://api.github.com/users/yigit/events{/privacy}", 21 | "received_events_url": "https://api.github.com/users/yigit/received_events", 22 | "type": "User", 23 | "site_admin": false 24 | }, 25 | "private": false, 26 | "html_url": "https://github.com/yigit/AckMate", 27 | "description": "TextMate plugin (Cocoa) shell for running 'ack'", 28 | "fork": true, 29 | "url": "https://api.github.com/repos/yigit/AckMate", 30 | "forks_url": "https://api.github.com/repos/yigit/AckMate/forks", 31 | "keys_url": "https://api.github.com/repos/yigit/AckMate/keys{/key_id}", 32 | "collaborators_url": "https://api.github.com/repos/yigit/AckMate/collaborators{/collaborator}", 33 | "teams_url": "https://api.github.com/repos/yigit/AckMate/teams", 34 | "hooks_url": "https://api.github.com/repos/yigit/AckMate/hooks", 35 | "issue_events_url": "https://api.github.com/repos/yigit/AckMate/issues/events{/number}", 36 | "events_url": "https://api.github.com/repos/yigit/AckMate/events", 37 | "assignees_url": "https://api.github.com/repos/yigit/AckMate/assignees{/user}", 38 | "branches_url": "https://api.github.com/repos/yigit/AckMate/branches{/branch}", 39 | "tags_url": "https://api.github.com/repos/yigit/AckMate/tags", 40 | "blobs_url": "https://api.github.com/repos/yigit/AckMate/git/blobs{/sha}", 41 | "git_tags_url": "https://api.github.com/repos/yigit/AckMate/git/tags{/sha}", 42 | "git_refs_url": "https://api.github.com/repos/yigit/AckMate/git/refs{/sha}", 43 | "trees_url": "https://api.github.com/repos/yigit/AckMate/git/trees{/sha}", 44 | "statuses_url": "https://api.github.com/repos/yigit/AckMate/statuses/{sha}", 45 | "languages_url": "https://api.github.com/repos/yigit/AckMate/languages", 46 | "stargazers_url": "https://api.github.com/repos/yigit/AckMate/stargazers", 47 | "contributors_url": "https://api.github.com/repos/yigit/AckMate/contributors", 48 | "subscribers_url": "https://api.github.com/repos/yigit/AckMate/subscribers", 49 | "subscription_url": "https://api.github.com/repos/yigit/AckMate/subscription", 50 | "commits_url": "https://api.github.com/repos/yigit/AckMate/commits{/sha}", 51 | "git_commits_url": "https://api.github.com/repos/yigit/AckMate/git/commits{/sha}", 52 | "comments_url": "https://api.github.com/repos/yigit/AckMate/comments{/number}", 53 | "issue_comment_url": "https://api.github.com/repos/yigit/AckMate/issues/comments{/number}", 54 | "contents_url": "https://api.github.com/repos/yigit/AckMate/contents/{+path}", 55 | "compare_url": "https://api.github.com/repos/yigit/AckMate/compare/{base}...{head}", 56 | "merges_url": "https://api.github.com/repos/yigit/AckMate/merges", 57 | "archive_url": "https://api.github.com/repos/yigit/AckMate/{archive_format}{/ref}", 58 | "downloads_url": "https://api.github.com/repos/yigit/AckMate/downloads", 59 | "issues_url": "https://api.github.com/repos/yigit/AckMate/issues{/number}", 60 | "pulls_url": "https://api.github.com/repos/yigit/AckMate/pulls{/number}", 61 | "milestones_url": "https://api.github.com/repos/yigit/AckMate/milestones{/number}", 62 | "notifications_url": "https://api.github.com/repos/yigit/AckMate/notifications{?since,all,participating}", 63 | "labels_url": "https://api.github.com/repos/yigit/AckMate/labels{/name}", 64 | "releases_url": "https://api.github.com/repos/yigit/AckMate/releases{/id}", 65 | "deployments_url": "https://api.github.com/repos/yigit/AckMate/deployments", 66 | "created_at": "2011-07-28T01:14:17Z", 67 | "updated_at": "2013-01-03T23:16:37Z", 68 | "pushed_at": "2010-06-24T10:35:07Z", 69 | "git_url": "git://github.com/yigit/AckMate.git", 70 | "ssh_url": "git@github.com:yigit/AckMate.git", 71 | "clone_url": "https://github.com/yigit/AckMate.git", 72 | "svn_url": "https://github.com/yigit/AckMate", 73 | "homepage": "", 74 | "size": 312, 75 | "stargazers_count": 1, 76 | "watchers_count": 1, 77 | "language": "Objective-C", 78 | "has_issues": false, 79 | "has_projects": true, 80 | "has_downloads": true, 81 | "has_wiki": true, 82 | "has_pages": false, 83 | "forks_count": 0, 84 | "mirror_url": null, 85 | "open_issues_count": 0, 86 | "forks": 0, 87 | "open_issues": 0, 88 | "watchers": 1, 89 | "default_branch": "master" 90 | }, 91 | { 92 | "id": 58401761, 93 | "name": "android-architecture", 94 | "full_name": "yigit/android-architecture", 95 | "owner": { 96 | "login": "yigit", 97 | "id": 89202, 98 | "avatar_url": "https://avatars3.githubusercontent.com/u/89202?v=3", 99 | "gravatar_id": "", 100 | "url": "https://api.github.com/users/yigit", 101 | "html_url": "https://github.com/yigit", 102 | "followers_url": "https://api.github.com/users/yigit/followers", 103 | "following_url": "https://api.github.com/users/yigit/following{/other_user}", 104 | "gists_url": "https://api.github.com/users/yigit/gists{/gist_id}", 105 | "starred_url": "https://api.github.com/users/yigit/starred{/owner}{/repo}", 106 | "subscriptions_url": "https://api.github.com/users/yigit/subscriptions", 107 | "organizations_url": "https://api.github.com/users/yigit/orgs", 108 | "repos_url": "https://api.github.com/users/yigit/repos", 109 | "events_url": "https://api.github.com/users/yigit/events{/privacy}", 110 | "received_events_url": "https://api.github.com/users/yigit/received_events", 111 | "type": "User", 112 | "site_admin": false 113 | }, 114 | "private": false, 115 | "html_url": "https://github.com/yigit/android-architecture", 116 | "description": "A collection of samples to discuss and showcase different architectural tools and patterns for Android apps.", 117 | "fork": true, 118 | "url": "https://api.github.com/repos/yigit/android-architecture", 119 | "forks_url": "https://api.github.com/repos/yigit/android-architecture/forks", 120 | "keys_url": "https://api.github.com/repos/yigit/android-architecture/keys{/key_id}", 121 | "collaborators_url": "https://api.github.com/repos/yigit/android-architecture/collaborators{/collaborator}", 122 | "teams_url": "https://api.github.com/repos/yigit/android-architecture/teams", 123 | "hooks_url": "https://api.github.com/repos/yigit/android-architecture/hooks", 124 | "issue_events_url": "https://api.github.com/repos/yigit/android-architecture/issues/events{/number}", 125 | "events_url": "https://api.github.com/repos/yigit/android-architecture/events", 126 | "assignees_url": "https://api.github.com/repos/yigit/android-architecture/assignees{/user}", 127 | "branches_url": "https://api.github.com/repos/yigit/android-architecture/branches{/branch}", 128 | "tags_url": "https://api.github.com/repos/yigit/android-architecture/tags", 129 | "blobs_url": "https://api.github.com/repos/yigit/android-architecture/git/blobs{/sha}", 130 | "git_tags_url": "https://api.github.com/repos/yigit/android-architecture/git/tags{/sha}", 131 | "git_refs_url": "https://api.github.com/repos/yigit/android-architecture/git/refs{/sha}", 132 | "trees_url": "https://api.github.com/repos/yigit/android-architecture/git/trees{/sha}", 133 | "statuses_url": "https://api.github.com/repos/yigit/android-architecture/statuses/{sha}", 134 | "languages_url": "https://api.github.com/repos/yigit/android-architecture/languages", 135 | "stargazers_url": "https://api.github.com/repos/yigit/android-architecture/stargazers", 136 | "contributors_url": "https://api.github.com/repos/yigit/android-architecture/contributors", 137 | "subscribers_url": "https://api.github.com/repos/yigit/android-architecture/subscribers", 138 | "subscription_url": "https://api.github.com/repos/yigit/android-architecture/subscription", 139 | "commits_url": "https://api.github.com/repos/yigit/android-architecture/commits{/sha}", 140 | "git_commits_url": "https://api.github.com/repos/yigit/android-architecture/git/commits{/sha}", 141 | "comments_url": "https://api.github.com/repos/yigit/android-architecture/comments{/number}", 142 | "issue_comment_url": "https://api.github.com/repos/yigit/android-architecture/issues/comments{/number}", 143 | "contents_url": "https://api.github.com/repos/yigit/android-architecture/contents/{+path}", 144 | "compare_url": "https://api.github.com/repos/yigit/android-architecture/compare/{base}...{head}", 145 | "merges_url": "https://api.github.com/repos/yigit/android-architecture/merges", 146 | "archive_url": "https://api.github.com/repos/yigit/android-architecture/{archive_format}{/ref}", 147 | "downloads_url": "https://api.github.com/repos/yigit/android-architecture/downloads", 148 | "issues_url": "https://api.github.com/repos/yigit/android-architecture/issues{/number}", 149 | "pulls_url": "https://api.github.com/repos/yigit/android-architecture/pulls{/number}", 150 | "milestones_url": "https://api.github.com/repos/yigit/android-architecture/milestones{/number}", 151 | "notifications_url": "https://api.github.com/repos/yigit/android-architecture/notifications{?since,all,participating}", 152 | "labels_url": "https://api.github.com/repos/yigit/android-architecture/labels{/name}", 153 | "releases_url": "https://api.github.com/repos/yigit/android-architecture/releases{/id}", 154 | "deployments_url": "https://api.github.com/repos/yigit/android-architecture/deployments", 155 | "created_at": "2016-05-09T19:17:26Z", 156 | "updated_at": "2017-03-15T16:33:01Z", 157 | "pushed_at": "2016-05-09T19:18:34Z", 158 | "git_url": "git://github.com/yigit/android-architecture.git", 159 | "ssh_url": "git@github.com:yigit/android-architecture.git", 160 | "clone_url": "https://github.com/yigit/android-architecture.git", 161 | "svn_url": "https://github.com/yigit/android-architecture", 162 | "homepage": "", 163 | "size": 6874, 164 | "stargazers_count": 40, 165 | "watchers_count": 40, 166 | "language": null, 167 | "has_issues": false, 168 | "has_projects": true, 169 | "has_downloads": true, 170 | "has_wiki": true, 171 | "has_pages": false, 172 | "forks_count": 5, 173 | "mirror_url": null, 174 | "open_issues_count": 0, 175 | "forks": 5, 176 | "open_issues": 0, 177 | "watchers": 40, 178 | "default_branch": "master" 179 | } 180 | ] -------------------------------------------------------------------------------- /api/src/test/resources/api-response/user-yigit.json: -------------------------------------------------------------------------------- 1 | { 2 | "login": "yigit", 3 | "id": 89202, 4 | "avatar_url": "https://avatars3.githubusercontent.com/u/89202?v=3", 5 | "gravatar_id": "", 6 | "url": "https://api.github.com/users/yigit", 7 | "html_url": "https://github.com/yigit", 8 | "followers_url": "https://api.github.com/users/yigit/followers", 9 | "following_url": "https://api.github.com/users/yigit/following{/other_user}", 10 | "gists_url": "https://api.github.com/users/yigit/gists{/gist_id}", 11 | "starred_url": "https://api.github.com/users/yigit/starred{/owner}{/repo}", 12 | "subscriptions_url": "https://api.github.com/users/yigit/subscriptions", 13 | "organizations_url": "https://api.github.com/users/yigit/orgs", 14 | "repos_url": "https://api.github.com/users/yigit/repos", 15 | "events_url": "https://api.github.com/users/yigit/events{/privacy}", 16 | "received_events_url": "https://api.github.com/users/yigit/received_events", 17 | "type": "User", 18 | "site_admin": false, 19 | "name": "Yigit Boyar", 20 | "company": "Google", 21 | "blog": "birbit.com", 22 | "location": "san francisco", 23 | "email": null, 24 | "hireable": null, 25 | "bio": null, 26 | "public_repos": 19, 27 | "public_gists": 2, 28 | "followers": 1135, 29 | "following": 8, 30 | "created_at": "2009-05-27T15:49:48Z", 31 | "updated_at": "2017-03-04T18:12:05Z" 32 | } -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | /.idea/workspace.xml 6 | /.idea/libraries 7 | .DS_Store 8 | /build 9 | /captures 10 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | apply plugin: 'com.android.application' 18 | apply plugin: 'kotlin-android' 19 | apply plugin: 'kotlin-kapt' 20 | apply plugin: 'jacoco' 21 | apply plugin: 'kotlin-android-extensions' 22 | 23 | androidExtensions { 24 | experimental = true 25 | } 26 | 27 | android { 28 | compileSdkVersion 28 29 | defaultConfig { 30 | applicationId "it.codingjam.github" 31 | minSdkVersion 16 32 | targetSdkVersion 28 33 | versionCode 1 34 | versionName "1.0" 35 | testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' 36 | } 37 | buildTypes { 38 | debug { 39 | testCoverageEnabled !project.hasProperty('android.injected.invoked.from.ide') 40 | } 41 | release { 42 | minifyEnabled false 43 | } 44 | } 45 | dataBinding { 46 | enabled = true 47 | } 48 | compileOptions { 49 | sourceCompatibility JavaVersion.VERSION_1_8 50 | targetCompatibility JavaVersion.VERSION_1_8 51 | } 52 | lintOptions { 53 | disable 'GoogleAppIndexingWarning' 54 | } 55 | packagingOptions { 56 | exclude 'META-INF/DEPENDENCIES' 57 | exclude 'META-INF/LICENSE' 58 | exclude 'META-INF/LICENSE.txt' 59 | exclude 'META-INF/license.txt' 60 | exclude 'META-INF/NOTICE' 61 | exclude 'META-INF/NOTICE.txt' 62 | exclude 'META-INF/notice.txt' 63 | exclude 'META-INF/ASL2.0' 64 | exclude 'META-INF/main.kotlin_module' 65 | exclude 'META-INF/atomicfu.kotlin_module' 66 | } 67 | } 68 | 69 | jacoco { 70 | toolVersion = "0.7.4+" 71 | } 72 | 73 | dependencies { 74 | implementation project(':api') 75 | implementation project(':core') 76 | implementation project(':viewlib') 77 | implementation project(':uirepo') 78 | implementation project(':uiuser') 79 | implementation project(':uisearch') 80 | 81 | implementation "com.jakewharton.timber:timber:$timber_version" 82 | 83 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 84 | kapt "androidx.lifecycle:lifecycle-compiler:$arch_version" 85 | 86 | testImplementation project(':testData') 87 | testImplementation "org.mockito:mockito-inline:$mockito_version" 88 | 89 | kaptAndroidTest "com.google.dagger:dagger-compiler:$dagger_version" 90 | androidTestImplementation project(':androidTestLib') 91 | androidTestImplementation project(':testData') 92 | } 93 | 94 | task fullCoverageReport(type: JacocoReport) { 95 | dependsOn 'createDebugCoverageReport' 96 | dependsOn 'testDebugUnitTest' 97 | reports { 98 | xml.enabled = true 99 | html.enabled = true 100 | } 101 | 102 | def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', 103 | '**/*Test*.*', 'android/**/*.*', 104 | 'com/android/databinding/**/*.class', 105 | '**/databinding/*.class', 106 | '**/*_MembersInjector.class', 107 | '**/Dagger*Component.class', 108 | '**/Dagger*Component$Builder.class', 109 | '**/*_*Factory.class', 110 | '**/*ComponentImpl.class', 111 | '**/*SubComponentBuilder.class'] 112 | def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug", excludes: fileFilter) 113 | def mainSrc = "${project.projectDir}/src/main/java" 114 | 115 | sourceDirectories = files([mainSrc]) 116 | classDirectories = files([debugTree]) 117 | executionData = fileTree(dir: "$buildDir", includes: [ 118 | "jacoco/testDebugUnitTest.exec", 119 | "outputs/code-coverage/connected/*coverage.ec" 120 | ]) 121 | } 122 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/java/it/codingjam/github/AndroidNavigationController.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github 2 | 3 | import com.google.android.material.snackbar.Snackbar 4 | import it.codingjam.github.core.RepoId 5 | import it.codingjam.github.ui.common.navigate 6 | import it.codingjam.github.ui.repo.RepoFragment 7 | import it.codingjam.github.ui.user.UserFragment 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class AndroidNavigationController @Inject constructor() : NavigationController { 13 | 14 | override fun navigateToRepo(fragment: androidx.fragment.app.Fragment, repoId: RepoId) { 15 | RepoFragment.navigate(fragment, repoId) 16 | } 17 | 18 | override fun navigateToUser(fragment: androidx.fragment.app.Fragment, login: String) { 19 | UserFragment.navigate(fragment, login) 20 | } 21 | 22 | override fun showError(activity: androidx.fragment.app.FragmentActivity, error: String?) { 23 | Snackbar.make(activity.findViewById(android.R.id.content), error 24 | ?: "Error", Snackbar.LENGTH_LONG).show() 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/it/codingjam/github/GithubApp.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github 18 | 19 | import android.app.Application 20 | import it.codingjam.github.api.DaggerApiComponent 21 | import it.codingjam.github.core.CoreDependencies 22 | import it.codingjam.github.core.utils.ComponentHolder 23 | import it.codingjam.github.core.utils.ComponentsMap 24 | import it.codingjam.github.core.utils.provide 25 | import timber.log.Timber 26 | 27 | 28 | class GithubApp : Application(), ComponentHolder by ComponentsMap() { 29 | 30 | override fun onCreate() { 31 | super.onCreate() 32 | if (BuildConfig.DEBUG) { 33 | Timber.plant(Timber.DebugTree()) 34 | } 35 | 36 | provide { 37 | DaggerApiComponent.create() 38 | } 39 | 40 | provide { 41 | object : ViewLibDependencies { 42 | override val navigationController = AndroidNavigationController() 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/it/codingjam/github/MainActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github 18 | 19 | import android.os.Bundle 20 | import androidx.appcompat.app.AppCompatActivity 21 | import androidx.navigation.findNavController 22 | import androidx.navigation.ui.NavigationUI 23 | import kotlinx.android.synthetic.main.main_activity.* 24 | 25 | 26 | class MainActivity : AppCompatActivity() { 27 | 28 | override fun onCreate(savedInstanceState: Bundle?) { 29 | super.onCreate(savedInstanceState) 30 | 31 | setContentView(R.layout.main_activity) 32 | 33 | setSupportActionBar(toolbar) 34 | 35 | NavigationUI.setupActionBarWithNavController(this, findNavController(R.id.my_nav_host_fragment)) 36 | } 37 | 38 | override fun onSupportNavigateUp() 39 | = findNavController(R.id.my_nav_host_fragment).navigateUp() 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/it/codingjam/github/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.di 18 | 19 | import dagger.Module 20 | import dagger.Provides 21 | import it.codingjam.github.AndroidNavigationController 22 | import it.codingjam.github.NavigationController 23 | import it.codingjam.github.util.ViewStateStoreFactory 24 | import kotlinx.coroutines.Dispatchers 25 | import javax.inject.Singleton 26 | 27 | @Module class AppModule { 28 | @Provides @Singleton fun navigationController(androidNavigationController: AndroidNavigationController): NavigationController = androidNavigationController 29 | 30 | @Provides @Singleton fun viewStateStoreFactory() = ViewStateStoreFactory(Dispatchers.IO) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/res/layout/main_activity.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | #3F51B5 20 | #303F9F 21 | #ff4081 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 18 | 19 | buildscript { 20 | ext.kotlin_version = '1.3.30' 21 | repositories { 22 | jcenter() 23 | google() 24 | maven { url "http://dl.bintray.com/kotlin/kotlin-eap" } 25 | } 26 | dependencies { 27 | classpath 'com.android.tools.build:gradle:3.3.2' 28 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 29 | classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version" 30 | 31 | // NOTE: Do not place your application dependencies here; they belong 32 | // in the individual module build.gradle files 33 | } 34 | } 35 | 36 | ext.arch_version = '2.1.0-alpha04' 37 | ext.support_version = '1.0.0' 38 | ext.dagger_version = "2.22.1" 39 | ext.junit_version = "4.12" 40 | ext.espresso_version = '3.1.0' 41 | ext.retrofit_version = "2.3.0" 42 | ext.okhttp_version = "3.8.1" 43 | ext.mockito_version = "2.23.0" 44 | ext.constraint_layout_version = '1.1.3' 45 | ext.glide_version = "3.7.0" 46 | ext.timber_version = "4.7.0" 47 | ext.nav_version = "1.0.0" 48 | 49 | allprojects { 50 | repositories { 51 | jcenter() 52 | mavenCentral() 53 | google() 54 | maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } 55 | maven { url "https://jitpack.io" } 56 | maven { url "http://dl.bintray.com/kotlin/kotlin-eap" } 57 | } 58 | } 59 | 60 | 61 | task clean(type: Delete) { 62 | delete rootProject.buildDir 63 | } 64 | -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /core/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: 'kotlin-android-extensions' 5 | apply plugin: "kotlin-allopen" 6 | 7 | allOpen { 8 | annotation("it.codingjam.github.core.AllOpen") 9 | } 10 | 11 | androidExtensions { 12 | experimental = true 13 | } 14 | 15 | android { 16 | compileSdkVersion 28 17 | 18 | defaultConfig { 19 | minSdkVersion 16 20 | targetSdkVersion 28 21 | versionCode 1 22 | versionName "1.0" 23 | } 24 | 25 | buildTypes { 26 | release { 27 | minifyEnabled false 28 | } 29 | } 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | } 35 | 36 | dependencies { 37 | implementation 'com.google.code.gson:gson:2.8.5' 38 | api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.0" 39 | api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.0" 40 | 41 | api "com.google.dagger:dagger:$dagger_version" 42 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 43 | 44 | api('com.github.nalulabs.prefs-delegates:prefs-delegates:0.1') { 45 | exclude group: 'org.jetbrains.kotlin' 46 | } 47 | 48 | api "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 49 | compileOnly 'com.github.pengrad:jdk9-deps:1.0' 50 | 51 | testImplementation project(':testData') 52 | } 53 | 54 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 55 | kotlinOptions{ 56 | freeCompilerArgs = ["-Xskip-metadata-version-check"] 57 | } 58 | } -------------------------------------------------------------------------------- /core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/AllOpen.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | @Target(AnnotationTarget.ANNOTATION_CLASS) 4 | annotation class AllOpen -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/Contributor.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.core 18 | 19 | import com.google.gson.annotations.SerializedName 20 | 21 | data class Contributor( 22 | val login: String, 23 | 24 | val contributions: Int, 25 | 26 | @SerializedName("avatar_url") 27 | val avatarUrl: String 28 | ) -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/CoreComponent.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | import dagger.Component 4 | import it.codingjam.github.core.utils.ComponentHolder 5 | import it.codingjam.github.core.utils.get 6 | import it.codingjam.github.core.utils.getOrCreate 7 | import javax.inject.Singleton 8 | 9 | interface CoreComponent { 10 | val githubInteractor: GithubInteractor 11 | } 12 | 13 | @Singleton 14 | @Component( 15 | dependencies = [CoreDependencies::class] 16 | ) 17 | interface CoreComponentImpl : CoreComponent { 18 | @Component.Factory 19 | interface Factory { 20 | fun create(dependencies: CoreDependencies): CoreComponent 21 | } 22 | } 23 | 24 | val ComponentHolder.coreComponent 25 | get() = getOrCreate { 26 | DaggerCoreComponentImpl.factory().create(get()) 27 | } 28 | 29 | interface CoreDependencies { 30 | val githubRepository: GithubRepository 31 | } 32 | -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/GithubInteractor.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | import kotlinx.coroutines.async 4 | import kotlinx.coroutines.coroutineScope 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @OpenForTesting 9 | @Singleton 10 | class GithubInteractor @Inject constructor(private val githubRepository: GithubRepository) { 11 | 12 | suspend fun search(query: String) = githubRepository.search(query) 13 | 14 | suspend fun searchNextPage(query: String, nextPage: Int) = githubRepository.searchNextPage(query, nextPage) 15 | 16 | suspend fun loadRepo(owner: String, name: String): RepoDetail = coroutineScope { 17 | val repo = async { githubRepository.getRepo(owner, name) } 18 | val contributors = async { githubRepository.getContributors(owner, name) } 19 | 20 | RepoDetail(repo.await(), contributors.await()) 21 | } 22 | 23 | suspend fun loadUserDetail(login: String): UserDetail = coroutineScope { 24 | val userDeferred = async { githubRepository.loadUser(login) } 25 | val reposDeferred = async { githubRepository.loadRepos(login) } 26 | UserDetail(userDeferred.await(), reposDeferred.await()) 27 | } 28 | } -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/GithubRepository.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | interface GithubRepository { 4 | suspend fun loadRepos(owner: String): List 5 | 6 | suspend fun searchNextPage(query: String, nextPage: Int): RepoSearchResponse 7 | suspend fun search(query: String): RepoSearchResponse 8 | 9 | suspend fun loadUser(login: String): User 10 | 11 | suspend fun getRepo(owner: String, name: String): Repo 12 | suspend fun getContributors(owner: String, name: String): List 13 | } 14 | -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/OpenForTesting.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | @AllOpen 4 | @Target(AnnotationTarget.CLASS) 5 | annotation class OpenForTesting -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/Owner.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | data class Owner(val login: String, val url: String) -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/Repo.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.core 18 | 19 | import com.google.gson.annotations.SerializedName 20 | 21 | data class Repo( 22 | val id: Int, 23 | val name: String, 24 | @SerializedName("full_name") 25 | val fullName: String, 26 | val description: String?, 27 | val owner: Owner, 28 | @SerializedName("stargazers_count") 29 | val stars: Int 30 | ) { 31 | inline val repoId get() = RepoId(owner.login, name) 32 | } 33 | -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/RepoDetail.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | data class RepoDetail( 4 | val repo: Repo, 5 | val contributors: List 6 | ) -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/RepoId.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | @Parcelize 7 | open class RepoId(val owner: String, val name: String) : Parcelable -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/RepoSearchResponse.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.core 18 | 19 | 20 | data class RepoSearchResponse( 21 | val items: List, 22 | val nextPage: Int? 23 | ) -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/User.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.core 18 | 19 | import com.google.gson.annotations.SerializedName 20 | 21 | data class User( 22 | 23 | val login: String, 24 | 25 | @SerializedName("avatar_url") 26 | val avatarUrl: String, 27 | 28 | val name: String, 29 | 30 | val company: String, 31 | 32 | @SerializedName("repos_url") 33 | val reposUrl: String, 34 | 35 | val blog: String 36 | ) -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/UserDetail.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | data class UserDetail ( 4 | val user: User, 5 | val repos: List 6 | ) -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/utils/ComponentHolder.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core.utils 2 | 3 | interface ComponentHolder { 4 | fun getOrCreate(key: Any, componentClass: Class, componentFactory: () -> C): C 5 | 6 | fun remove(key: Any) 7 | 8 | fun init(interceptor: (Class<*>, () -> Any) -> Any) 9 | } 10 | 11 | inline fun ComponentHolder.getOrCreate(noinline componentFactory: () -> C): C = 12 | getOrCreate(C::class.java, C::class.java, componentFactory) 13 | 14 | inline fun ComponentHolder.get(): C = 15 | getOrCreate(C::class.java, C::class.java) { 16 | throw Exception("Component ${C::class.java.simpleName} not available in ${this::class.java.simpleName}") 17 | } 18 | 19 | inline fun ComponentHolder.provide(noinline componentFactory: () -> C) { 20 | getOrCreate(C::class.java, C::class.java, componentFactory) 21 | } 22 | 23 | class ComponentsMap : ComponentHolder { 24 | private var interceptor: (Class<*>, () -> Any) -> Any = { _, factory -> factory() } 25 | 26 | private val moduleComponents = HashMap() 27 | 28 | override fun getOrCreate(key: Any, componentClass: Class, componentFactory: () -> C): C { 29 | @Suppress("UNCHECKED_CAST") 30 | return moduleComponents.getOrPut(key) { 31 | interceptor(componentClass, componentFactory) 32 | } as C 33 | } 34 | 35 | override fun init(interceptor: (Class<*>, () -> Any) -> Any) { 36 | this.interceptor = interceptor 37 | moduleComponents.clear() 38 | } 39 | 40 | override fun remove(key: Any) { 41 | moduleComponents.remove(key) 42 | } 43 | } -------------------------------------------------------------------------------- /core/src/main/java/it/codingjam/github/core/utils/deepCopy.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core.utils 2 | 3 | inline fun C1.deepCopy( 4 | field1: C1.() -> C2, f1: C1.(C2) -> C1, 5 | f: C2.(C1) -> C2): C1 { 6 | val value2 = field1() 7 | val newValue2 = value2.f(this) 8 | return f1(newValue2) 9 | } 10 | 11 | inline fun C1.deepCopy( 12 | field1: C1.() -> C2, f1: C1.(C2) -> C1, 13 | field2: C2.() -> C3, f2: C2.(C3) -> C2, 14 | f: C3.(C2) -> C3): C1 { 15 | val value2 = field1() 16 | val value3 = value2.field2() 17 | val newValue3 = value3.f(value2) 18 | val newValue2 = value2.f2(newValue3) 19 | return f1(newValue2) 20 | } 21 | 22 | inline fun C1.deepCopy( 23 | field1: C1.() -> C2, f1: C1.(C2) -> C1, 24 | field2: C2.() -> C3, f2: C2.(C3) -> C2, 25 | field3: C3.() -> C4, f3: C3.(C4) -> C3, 26 | f: C4.(C3) -> C4): C1 { 27 | val value2 = field1() 28 | val value3 = value2.field2() 29 | val value4 = value3.field3() 30 | val newValue4 = value4.f(value3) 31 | val newValue3 = value3.f3(newValue4) 32 | val newValue2 = value2.f2(newValue3) 33 | return f1(newValue2) 34 | } 35 | -------------------------------------------------------------------------------- /core/src/test/java/it/codingjam/github/core/GithubInteractorTest.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.core 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import com.nhaarman.mockitokotlin2.mock 6 | import com.nhaarman.mockitokotlin2.whenever 7 | import kotlinx.coroutines.runBlocking 8 | import org.junit.Test 9 | 10 | class GithubInteractorTest { 11 | 12 | val githubRepository: GithubRepository = mock() 13 | 14 | val githubInteractor = GithubInteractor(githubRepository) 15 | 16 | @Test fun load() = runBlocking { 17 | whenever(githubRepository.loadUser(LOGIN)).thenReturn(USER) 18 | whenever(githubRepository.loadRepos(LOGIN)).thenReturn(listOf(REPO_1, REPO_2)) 19 | 20 | val detail = githubInteractor.loadUserDetail(LOGIN) 21 | assertThat(detail).isEqualTo(UserDetail(USER, listOf(REPO_1, REPO_2))) 22 | } 23 | 24 | companion object { 25 | private const val LOGIN = "login" 26 | private val OWNER = Owner("login", "url") 27 | private val USER = User("login", "avatar", "name", "company", "repos", "blog") 28 | private val REPO_1 = Repo(1, "name", "fullName", "desc", OWNER, 10000) 29 | private val REPO_2 = Repo(2, "name", "fullName", "desc", OWNER, 10000) 30 | } 31 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (C) 2017 The Android Open Source Project 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | # Project-wide Gradle settings. 18 | 19 | # IDE (e.g. Android Studio) users: 20 | # Gradle settings configured through the IDE *will override* 21 | # any settings specified in this file. 22 | 23 | # For more details on how to configure your build environment visit 24 | # http://www.gradle.org/docs/current/userguide/build_environment.html 25 | 26 | # Specifies the JVM arguments used for the daemon process. 27 | # The setting is particularly useful for tweaking memory settings. 28 | org.gradle.jvmargs=-Xmx4536m 29 | 30 | # When configured, Gradle will run in incubating parallel mode. 31 | # This option should only be used with decoupled projects. More details, visit 32 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 33 | # org.gradle.parallel=true 34 | 35 | org.gradle.caching=true 36 | android.useAndroidX=true 37 | android.enableJetifier=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabioCollini/ArchitectureComponentsDemo/5248bae79f8faaba81ec2ef87bec1a402d475763/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Aug 26 10:24:59 CEST 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 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 %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="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 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | include ':app', ':core', ':api', ':viewlib', ':testData', ':androidTestLib', ':uisearch', ':uisearchTest', ':uiuser', ':uiuserTest', ':uirepo', ':uirepoTest' 18 | -------------------------------------------------------------------------------- /testData/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /testData/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | 5 | android { 6 | compileSdkVersion 28 7 | 8 | defaultConfig { 9 | minSdkVersion 16 10 | targetSdkVersion 28 11 | versionCode 1 12 | versionName "1.0" 13 | } 14 | 15 | buildTypes { 16 | release { 17 | minifyEnabled false 18 | } 19 | } 20 | compileOptions { 21 | sourceCompatibility JavaVersion.VERSION_1_8 22 | targetCompatibility JavaVersion.VERSION_1_8 23 | } 24 | } 25 | 26 | dependencies { 27 | api project(':core') 28 | api project(':viewlib') 29 | 30 | api "junit:junit:$junit_version" 31 | api "org.mockito:mockito-core:$mockito_version" 32 | api 'com.willowtreeapps.assertk:assertk-jvm:0.13' 33 | api('com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0') { 34 | exclude group: 'org.jetbrains.kotlin' 35 | } 36 | api ("androidx.arch.core:core-testing:2.0.1", { 37 | exclude group: 'com.android.support', module: 'support-compat' 38 | exclude group: 'com.android.support', module: 'support-annotations' 39 | exclude group: 'com.android.support', module: 'support-core-utils' 40 | }) 41 | api('com.github.nalulabs.prefs-delegates:fake-prefs:0.1') { 42 | exclude group: 'org.jetbrains.kotlin' 43 | } 44 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 45 | 46 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 47 | } 48 | -------------------------------------------------------------------------------- /testData/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /testData/src/main/java/it/codingjam/github/testdata/CustomAssertions.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.testdata 2 | 3 | import assertk.Assert 4 | import assertk.assertions.isEqualTo 5 | import it.codingjam.github.util.ActionsFlow 6 | import it.codingjam.github.util.StateAction 7 | import it.codingjam.github.vo.Lce 8 | import kotlinx.coroutines.flow.fold 9 | import kotlinx.coroutines.runBlocking 10 | import org.mockito.Mockito 11 | import org.mockito.stubbing.OngoingStubbing 12 | 13 | 14 | fun Assert>.containsLce(expected: String) = given { actual -> 15 | val actualString = actual.map { 16 | when (it) { 17 | is Lce.Success<*> -> "S" 18 | is Lce.Loading -> "L" 19 | is Lce.Error -> "E" 20 | else -> "N" 21 | } 22 | }.joinToString("") 23 | assertThat(actualString).isEqualTo(expected) 24 | } 25 | 26 | fun Assert>.map(extract: (T) -> P): Assert> = 27 | transform { it.map(extract) } 28 | 29 | fun on(methodCall: suspend () -> T): OngoingStubbing { 30 | return Mockito.`when`(runBlocking { methodCall() })!! 31 | } 32 | 33 | fun ActionsFlow.states(initialState: T): List = runBlocking { 34 | fold(initialState to emptyList()) { (prevState, states), action -> 35 | if (action is StateAction) { 36 | val curState = action(prevState) 37 | curState to states + curState 38 | } else 39 | prevState to states + action 40 | }.second 41 | } -------------------------------------------------------------------------------- /testData/src/main/java/it/codingjam/github/testdata/TestAppModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.testdata 18 | 19 | import android.os.AsyncTask 20 | import com.nhaarman.mockitokotlin2.mock 21 | import dagger.Module 22 | import dagger.Provides 23 | import it.codingjam.github.NavigationController 24 | import it.codingjam.github.core.GithubInteractor 25 | import it.codingjam.github.core.GithubRepository 26 | import it.codingjam.github.util.ViewStateStoreFactory 27 | import kotlinx.coroutines.asCoroutineDispatcher 28 | import javax.inject.Singleton 29 | 30 | @Module 31 | open class TestAppModule { 32 | @Provides @Singleton open fun navigationController() = mock() 33 | 34 | @Provides @Singleton open fun githubRepository() = mock() 35 | 36 | @Provides @Singleton open fun githubInteractor() = mock() 37 | 38 | @Provides @Singleton fun viewStateStoreFactory() = ViewStateStoreFactory(TEST_DISPATCHER) 39 | } 40 | 41 | val TEST_DISPATCHER = AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher() 42 | -------------------------------------------------------------------------------- /testData/src/main/java/it/codingjam/github/testdata/TestData.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.testdata 2 | 3 | import it.codingjam.github.core.* 4 | 5 | object TestData { 6 | val OWNER = Owner("login", "url") 7 | val REPO_1 = Repo(1, "name", "fullName", "desc", OWNER, 10000) 8 | val REPO_2 = Repo(2, "name", "fullName", "desc", OWNER, 10000) 9 | val REPO_3 = Repo(3, "name", "fullName", "desc", OWNER, 10000) 10 | val REPO_4 = Repo(4, "name", "fullName", "desc", OWNER, 10000) 11 | val CONTRIBUTOR1 = Contributor("login1", 10, "url1") 12 | val CONTRIBUTOR2 = Contributor("login2", 20, "url2") 13 | val REPO_DETAIL = RepoDetail(REPO_1, listOf(CONTRIBUTOR1, CONTRIBUTOR2)) 14 | val USER = User("login", "avatar", "name", "company", "repos", "blog") 15 | } 16 | -------------------------------------------------------------------------------- /uirepo/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /uirepo/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: "kotlin-allopen" 6 | 7 | allOpen { 8 | annotation("it.codingjam.github.core.AllOpen") 9 | } 10 | 11 | android { 12 | compileSdkVersion 28 13 | 14 | defaultConfig { 15 | minSdkVersion 16 16 | targetSdkVersion 28 17 | versionCode 1 18 | versionName "1.0" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | } 25 | } 26 | 27 | dataBinding { 28 | enabled = true 29 | } 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | } 35 | 36 | dependencies { 37 | api project(':core') 38 | api project(':viewlib') 39 | 40 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 41 | 42 | testImplementation project(':testData') 43 | testImplementation "org.mockito:mockito-inline:$mockito_version" 44 | } 45 | -------------------------------------------------------------------------------- /uirepo/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /uirepo/src/main/java/it/codingjam/github/ui/repo/ContributorViewHolder.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.repo 2 | 3 | 4 | import android.view.ViewGroup 5 | import it.codingjam.github.core.Contributor 6 | import it.codingjam.github.ui.common.DataBoundViewHolder 7 | import it.codingjam.github.ui.repo.databinding.ContributorItemBinding 8 | 9 | class ContributorViewHolder(parent: ViewGroup, private val viewModel: RepoViewModel) : 10 | DataBoundViewHolder(parent, ContributorItemBinding::inflate) { 11 | 12 | init { 13 | binding.viewHolder = this 14 | } 15 | 16 | override fun bind(t: Contributor) { 17 | binding.contributor = t 18 | } 19 | 20 | fun openUserDetail() = viewModel.openUserDetail(item.login) 21 | } 22 | -------------------------------------------------------------------------------- /uirepo/src/main/java/it/codingjam/github/ui/repo/RepoFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.repo 18 | 19 | import android.os.Bundle 20 | import android.view.LayoutInflater 21 | import android.view.View 22 | import android.view.ViewGroup 23 | import androidx.fragment.app.Fragment 24 | import it.codingjam.github.core.RepoDetail 25 | import it.codingjam.github.core.RepoId 26 | import it.codingjam.github.ui.common.DataBoundListAdapter 27 | import it.codingjam.github.ui.common.FragmentCreator 28 | import it.codingjam.github.ui.repo.databinding.RepoFragmentBinding 29 | import it.codingjam.github.util.ErrorSignal 30 | import it.codingjam.github.util.LceContainer 31 | import it.codingjam.github.util.NavigationSignal 32 | import it.codingjam.github.util.viewModel 33 | import it.codingjam.github.viewLibComponent 34 | 35 | class RepoFragment : Fragment() { 36 | 37 | lateinit var lceContainer: LceContainer 38 | 39 | private val navigationController by lazy { 40 | requireActivity().application.viewLibComponent.navigationController 41 | } 42 | 43 | private val viewModel: RepoViewModel by viewModel { 44 | repoFragmentComponent.viewModel.apply { reload() } 45 | } 46 | 47 | override fun onActivityCreated(savedInstanceState: Bundle?) { 48 | super.onActivityCreated(savedInstanceState) 49 | 50 | viewModel.state.observe(this) { 51 | lceContainer.lce = it 52 | } 53 | viewModel.state.observeSignals(this) { 54 | when (it) { 55 | is ErrorSignal -> navigationController.showError(requireActivity(), it.message) 56 | is NavigationSignal<*> -> navigationController.navigateToUser(this, it.params as String) 57 | } 58 | } 59 | } 60 | 61 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 62 | lceContainer = LceContainer(requireContext()) { 63 | viewModel.reload() 64 | } 65 | 66 | val adapter = DataBoundListAdapter { ContributorViewHolder(it, viewModel) } 67 | 68 | val binding = RepoFragmentBinding.inflate(inflater, lceContainer, true) 69 | 70 | binding.contributorList.adapter = adapter 71 | 72 | lceContainer.setUpdateListener { 73 | binding.repo = it.repo 74 | adapter.replace(it.contributors) 75 | binding.executePendingBindings() 76 | } 77 | 78 | return lceContainer 79 | } 80 | 81 | companion object : FragmentCreator(R.navigation.repo_nav_graph, R.id.repo) 82 | } 83 | -------------------------------------------------------------------------------- /uirepo/src/main/java/it/codingjam/github/ui/repo/RepoModule.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.repo 2 | 3 | import android.app.Application 4 | import androidx.fragment.app.Fragment 5 | import dagger.BindsInstance 6 | import dagger.Component 7 | import it.codingjam.github.* 8 | import it.codingjam.github.core.CoreComponent 9 | import it.codingjam.github.core.RepoId 10 | import it.codingjam.github.core.coreComponent 11 | import it.codingjam.github.core.utils.ComponentHolder 12 | import it.codingjam.github.ui.repo.RepoFragment.Companion.param 13 | 14 | @FeatureAppScope 15 | @Component(dependencies = [ViewLibComponent::class, CoreComponent::class]) 16 | interface RepoAppComponent : ViewLibComponent { 17 | val repoUseCase: RepoUseCase 18 | 19 | @Component.Factory 20 | interface Factory { 21 | fun create(core: CoreComponent, viewLib: ViewLibComponent): RepoAppComponent 22 | } 23 | } 24 | 25 | val Application.repoComponent 26 | get() = getOrCreate { 27 | DaggerRepoAppComponent.factory() 28 | .create((this as ComponentHolder).coreComponent, viewLibComponent) 29 | } 30 | 31 | @FeatureFragmentScope 32 | @Component(dependencies = [RepoAppComponent::class]) 33 | interface RepoFragmentComponent { 34 | val viewModel: RepoViewModel 35 | 36 | @Component.Factory 37 | interface Factory { 38 | fun create(@BindsInstance param: RepoId, repoAppComponent: RepoAppComponent): RepoFragmentComponent 39 | } 40 | } 41 | 42 | val Fragment.repoFragmentComponent: RepoFragmentComponent 43 | get() = getOrCreateFragmentComponent { 44 | DaggerRepoFragmentComponent.factory() 45 | .create(param, requireActivity().application.repoComponent) 46 | } -------------------------------------------------------------------------------- /uirepo/src/main/java/it/codingjam/github/ui/repo/RepoUseCase.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.repo 18 | 19 | import it.codingjam.github.FeatureAppScope 20 | import it.codingjam.github.core.GithubInteractor 21 | import it.codingjam.github.core.OpenForTesting 22 | import it.codingjam.github.core.RepoId 23 | import it.codingjam.github.util.NavigationSignal 24 | import it.codingjam.github.vo.lce 25 | import javax.inject.Inject 26 | 27 | @FeatureAppScope 28 | @OpenForTesting 29 | class RepoUseCase @Inject constructor( 30 | private val githubInteractor: GithubInteractor 31 | ) { 32 | 33 | fun reload(repoId: RepoId) = lce { 34 | githubInteractor.loadRepo(repoId.owner, repoId.name) 35 | } 36 | 37 | fun openUserDetail(login: String) = NavigationSignal("user", login) 38 | } 39 | -------------------------------------------------------------------------------- /uirepo/src/main/java/it/codingjam/github/ui/repo/RepoViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.repo 18 | 19 | import androidx.lifecycle.ViewModel 20 | import androidx.lifecycle.viewModelScope 21 | import it.codingjam.github.FeatureFragmentScope 22 | import it.codingjam.github.core.OpenForTesting 23 | import it.codingjam.github.core.RepoId 24 | import it.codingjam.github.util.ViewStateStoreFactory 25 | import it.codingjam.github.vo.Lce 26 | import javax.inject.Inject 27 | 28 | @OpenForTesting 29 | @FeatureFragmentScope 30 | class RepoViewModel @Inject constructor( 31 | private val useCase: RepoUseCase, 32 | private val repoId: RepoId, 33 | factory: ViewStateStoreFactory 34 | ) : ViewModel() { 35 | 36 | val state = factory(Lce.Loading, viewModelScope) 37 | 38 | fun reload() = state.dispatchActions(useCase.reload(repoId)) 39 | 40 | fun openUserDetail(login: String) = state.dispatchSignal(useCase.openUserDetail(login)) 41 | } 42 | -------------------------------------------------------------------------------- /uirepo/src/main/java/it/codingjam/github/ui/repo/RepoViewState.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.repo 2 | 3 | import it.codingjam.github.core.RepoDetail 4 | import it.codingjam.github.vo.Lce 5 | 6 | typealias RepoViewState = Lce -------------------------------------------------------------------------------- /uirepo/src/main/res/layout/contributor_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 21 | 22 | 23 | 24 | 27 | 28 | 31 | 32 | 33 | 38 | 39 | 42 | 43 | 60 | 61 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /uirepo/src/main/res/layout/repo_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 33 | 34 | 46 | 47 | 59 | 60 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /uirepo/src/main/res/navigation/repo_nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | -------------------------------------------------------------------------------- /uirepo/src/test/java/it/codingjam/github/ui/repo/RepoUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.repo 18 | 19 | import assertk.assertThat 20 | import com.nhaarman.mockitokotlin2.doReturn 21 | import com.nhaarman.mockitokotlin2.doThrow 22 | import com.nhaarman.mockitokotlin2.mock 23 | import it.codingjam.github.core.GithubInteractor 24 | import it.codingjam.github.core.RepoId 25 | import it.codingjam.github.testdata.TestData 26 | import it.codingjam.github.testdata.containsLce 27 | import it.codingjam.github.testdata.on 28 | import it.codingjam.github.testdata.states 29 | import it.codingjam.github.vo.Lce 30 | import org.junit.Test 31 | 32 | class RepoUseCaseTest { 33 | 34 | val interactor: GithubInteractor = mock() 35 | 36 | val useCase = RepoUseCase(interactor) 37 | 38 | @Test 39 | fun fetchData() { 40 | on { interactor.loadRepo("a", "b") } doReturn TestData.REPO_DETAIL 41 | 42 | val states = useCase.reload(RepoId("a", "b")) 43 | .states(Lce.Loading) 44 | 45 | assertThat(states).containsLce("LS") 46 | } 47 | 48 | @Test 49 | fun errorFetchingData() { 50 | on { interactor.loadRepo("a", "b") } doThrow RuntimeException() 51 | 52 | val states = useCase.reload(RepoId("a", "b")) 53 | .states(Lce.Loading) 54 | 55 | assertThat(states).containsLce("LE") 56 | } 57 | 58 | @Test 59 | fun retry() { 60 | on { interactor.loadRepo("a", "b") } 61 | .thenThrow(RuntimeException()) 62 | .thenReturn(TestData.REPO_DETAIL) 63 | 64 | val states = useCase.reload(RepoId("a", "b")).states(Lce.Loading) + 65 | useCase.reload(RepoId("a", "b")).states(Lce.Loading) 66 | 67 | assertThat(states).containsLce("LELS") 68 | } 69 | } -------------------------------------------------------------------------------- /uirepoTest/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /uirepoTest/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: "kotlin-allopen" 5 | 6 | allOpen { 7 | annotation("it.codingjam.github.core.AllOpen") 8 | } 9 | 10 | android { 11 | compileSdkVersion 28 12 | 13 | defaultConfig { 14 | applicationId "it.codingjam.github" 15 | minSdkVersion 16 16 | targetSdkVersion 28 17 | versionCode 1 18 | versionName "1.0" 19 | 20 | testInstrumentationRunner 'it.codingjam.github.espresso.MockTestRunner' 21 | } 22 | 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | } 27 | } 28 | dataBinding { 29 | enabled = true 30 | } 31 | packagingOptions { 32 | exclude 'META-INF/DEPENDENCIES' 33 | exclude 'META-INF/LICENSE' 34 | exclude 'META-INF/LICENSE.txt' 35 | exclude 'META-INF/license.txt' 36 | exclude 'META-INF/NOTICE' 37 | exclude 'META-INF/NOTICE.txt' 38 | exclude 'META-INF/notice.txt' 39 | exclude 'META-INF/ASL2.0' 40 | exclude 'META-INF/main.kotlin_module' 41 | exclude 'META-INF/atomicfu.kotlin_module' 42 | } 43 | compileOptions { 44 | sourceCompatibility JavaVersion.VERSION_1_8 45 | targetCompatibility JavaVersion.VERSION_1_8 46 | } 47 | } 48 | 49 | dependencies { 50 | implementation project(':uirepo') 51 | 52 | kaptAndroidTest "com.google.dagger:dagger-compiler:$dagger_version" 53 | androidTestImplementation project(':androidTestLib') 54 | androidTestImplementation project(':testData') 55 | } 56 | -------------------------------------------------------------------------------- /uirepoTest/src/androidTest/java/it/codingjam/github/ui/repo/RepoFragmentTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.repo 18 | 19 | import android.app.Application 20 | import androidx.annotation.StringRes 21 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 22 | import androidx.test.core.app.ApplicationProvider 23 | import androidx.test.espresso.Espresso.onView 24 | import androidx.test.espresso.assertion.ViewAssertions.matches 25 | import androidx.test.espresso.matcher.ViewMatchers.* 26 | import com.nhaarman.mockitokotlin2.doAnswer 27 | import com.nhaarman.mockitokotlin2.mock 28 | import it.codingjam.github.core.RepoDetail 29 | import it.codingjam.github.core.RepoId 30 | import it.codingjam.github.espresso.TestApplication 31 | import it.codingjam.github.espresso.rule 32 | import it.codingjam.github.testdata.TEST_DISPATCHER 33 | import it.codingjam.github.testdata.TestData.CONTRIBUTOR1 34 | import it.codingjam.github.testdata.TestData.CONTRIBUTOR2 35 | import it.codingjam.github.testdata.TestData.OWNER 36 | import it.codingjam.github.testdata.TestData.REPO_1 37 | import it.codingjam.github.util.ViewStateStore 38 | import it.codingjam.github.vo.Lce 39 | import it.cosenonjaviste.daggermock.DaggerMock 40 | import it.cosenonjaviste.daggermock.interceptor 41 | import kotlinx.coroutines.CoroutineScope 42 | import kotlinx.coroutines.Dispatchers 43 | import org.hamcrest.Matchers.not 44 | import org.junit.Before 45 | import org.junit.Rule 46 | import org.junit.Test 47 | 48 | 49 | class RepoFragmentTest { 50 | 51 | @get:Rule 52 | val fragmentRule = RepoFragment.rule() 53 | 54 | @get:Rule 55 | val instantExecutorRule = InstantTaskExecutorRule() 56 | 57 | private val viewStateStore by lazy { 58 | ViewStateStore(Lce.Loading, CoroutineScope(Dispatchers.Main), TEST_DISPATCHER) 59 | } 60 | 61 | private val viewModel = mock { 62 | on(it.state) doAnswer { viewStateStore } 63 | } 64 | 65 | @Before 66 | fun setUp() { 67 | val app = ApplicationProvider.getApplicationContext() 68 | app.init(DaggerMock.interceptor(this)) 69 | } 70 | 71 | @Test 72 | fun testLoading() { 73 | fragmentRule.launchFragment(RepoId("a", "b")) 74 | 75 | viewStateStore.dispatchState(Lce.Loading) 76 | 77 | onView(withId(R.id.progress_bar)).check(matches(isDisplayed())) 78 | onView(withId(R.id.retry)).check(matches(not(isDisplayed()))) 79 | } 80 | 81 | @Test 82 | fun testValueWhileLoading() { 83 | fragmentRule.launchFragment(RepoId("a", "b")) 84 | 85 | viewStateStore.dispatchState(Lce.Loading) 86 | viewStateStore.dispatchState(Lce.Success(RepoDetail(REPO_1, listOf(CONTRIBUTOR1, CONTRIBUTOR2)))) 87 | 88 | onView(withId(R.id.progress_bar)).check(matches(not(isDisplayed()))) 89 | onView(withId(R.id.name)).check(matches( 90 | withText(getString(R.string.repo_full_name, OWNER.login, REPO_1.name)))) 91 | onView(withId(R.id.description)).check(matches(withText(REPO_1.description))) 92 | } 93 | 94 | private fun getString(@StringRes id: Int, vararg args: Any) = ApplicationProvider.getApplicationContext().getString(id, *args) 95 | } -------------------------------------------------------------------------------- /uirepoTest/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /uisearch/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /uisearch/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: "kotlin-allopen" 6 | 7 | allOpen { 8 | annotation("it.codingjam.github.core.AllOpen") 9 | } 10 | 11 | android { 12 | compileSdkVersion 28 13 | 14 | defaultConfig { 15 | minSdkVersion 16 16 | targetSdkVersion 28 17 | versionCode 1 18 | versionName "1.0" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | } 25 | } 26 | 27 | dataBinding { 28 | enabled = true 29 | } 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | } 35 | 36 | dependencies { 37 | api project(':core') 38 | api project(':viewlib') 39 | 40 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 41 | 42 | testImplementation project(':testData') 43 | testImplementation "org.mockito:mockito-inline:$mockito_version" 44 | } 45 | 46 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 47 | kotlinOptions{ 48 | freeCompilerArgs = ["-Xskip-metadata-version-check"] 49 | } 50 | } -------------------------------------------------------------------------------- /uisearch/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /uisearch/src/main/java/it/codingjam/github/ui/search/RepoViewHolder.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.search 2 | 3 | 4 | import android.view.ViewGroup 5 | import it.codingjam.github.core.Repo 6 | import it.codingjam.github.ui.common.DataBoundViewHolder 7 | import it.codingjam.github.viewlib.databinding.RepoItemBinding 8 | 9 | class RepoViewHolder(parent: ViewGroup, viewModel: SearchViewModel) : 10 | DataBoundViewHolder(parent, RepoItemBinding::inflate) { 11 | init { 12 | binding.showFullName = true 13 | binding.root.setOnClickListener { 14 | viewModel.openRepoDetail(item.repoId) 15 | } 16 | } 17 | 18 | override fun bind(t: Repo) { 19 | binding.repo = t 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /uisearch/src/main/java/it/codingjam/github/ui/search/SearchFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.search 18 | 19 | import android.os.Bundle 20 | import android.view.LayoutInflater 21 | import android.view.View 22 | import android.view.ViewGroup 23 | import it.codingjam.github.core.RepoId 24 | import it.codingjam.github.ui.common.DataBoundListAdapter 25 | import it.codingjam.github.ui.search.databinding.SearchFragmentBinding 26 | import it.codingjam.github.util.ErrorSignal 27 | import it.codingjam.github.util.NavigationSignal 28 | import it.codingjam.github.util.viewModel 29 | import it.codingjam.github.viewLibComponent 30 | 31 | class SearchFragment : androidx.fragment.app.Fragment() { 32 | 33 | private val navigationController by lazy { 34 | requireActivity().application.viewLibComponent.navigationController 35 | } 36 | 37 | private val viewModel by viewModel { 38 | searchFragmentComponent.viewModel 39 | } 40 | 41 | private lateinit var binding: SearchFragmentBinding 42 | 43 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, 44 | savedInstanceState: Bundle?): View? { 45 | binding = SearchFragmentBinding.inflate(inflater, container, false) 46 | return binding.root 47 | } 48 | 49 | override fun onActivityCreated(savedInstanceState: Bundle?) { 50 | super.onActivityCreated(savedInstanceState) 51 | val adapter = DataBoundListAdapter { RepoViewHolder(it, viewModel) } 52 | binding.results.repoList.adapter = adapter 53 | 54 | binding.results.repoList.addOnScrollListener(object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() { 55 | override fun onScrolled(recyclerView: androidx.recyclerview.widget.RecyclerView, dx: Int, dy: Int) { 56 | val layoutManager = recyclerView.layoutManager as androidx.recyclerview.widget.LinearLayoutManager 57 | val lastPosition = layoutManager 58 | .findLastVisibleItemPosition() 59 | if (lastPosition == adapter.itemCount - 1) { 60 | viewModel.loadNextPage() 61 | } 62 | } 63 | }) 64 | 65 | binding.lce.setUpdateListener { 66 | adapter.replace((it as ReposViewState).list) 67 | } 68 | 69 | viewModel.state.observe(this) { 70 | binding.state = it 71 | binding.executePendingBindings() 72 | } 73 | viewModel.state.observeSignals(this) { 74 | when (it) { 75 | is ErrorSignal -> navigationController.showError(requireActivity(), it.message) 76 | is NavigationSignal<*> -> navigationController.navigateToRepo(this, it.params as RepoId) 77 | } 78 | } 79 | 80 | binding.viewModel = viewModel 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /uisearch/src/main/java/it/codingjam/github/ui/search/SearchModule.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.search 2 | 3 | import android.app.Application 4 | import androidx.fragment.app.Fragment 5 | import dagger.Component 6 | import it.codingjam.github.* 7 | import it.codingjam.github.core.CoreComponent 8 | import it.codingjam.github.core.coreComponent 9 | import it.codingjam.github.core.utils.ComponentHolder 10 | import it.codingjam.github.core.utils.getOrCreate 11 | 12 | @FeatureAppScope 13 | @Component(dependencies = [ViewLibComponent::class, CoreComponent::class]) 14 | interface SearchAppComponent: ViewLibComponent { 15 | val searchUseCase: SearchUseCase 16 | 17 | @Component.Factory 18 | interface Factory { 19 | fun create(core: CoreComponent, viewLib: ViewLibComponent): SearchAppComponent 20 | } 21 | } 22 | 23 | val Application.searchComponent 24 | get() = (this as ComponentHolder).getOrCreate { 25 | DaggerSearchAppComponent.factory() 26 | .create(coreComponent, viewLibComponent) 27 | } 28 | 29 | @FeatureFragmentScope 30 | @Component(dependencies = [SearchAppComponent::class]) 31 | interface SearchFragmentComponent { 32 | val viewModel: SearchViewModel 33 | 34 | @Component.Factory 35 | interface Factory { 36 | fun create(appComponent: SearchAppComponent): SearchFragmentComponent 37 | } 38 | } 39 | 40 | val Fragment.searchFragmentComponent: SearchFragmentComponent 41 | get() = getOrCreateFragmentComponent { 42 | DaggerSearchFragmentComponent.factory() 43 | .create(requireActivity().application.searchComponent) 44 | } -------------------------------------------------------------------------------- /uisearch/src/main/java/it/codingjam/github/ui/search/SearchUseCase.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.search 2 | 3 | import android.content.SharedPreferences 4 | import com.nalulabs.prefs.string 5 | import it.codingjam.github.FeatureAppScope 6 | import it.codingjam.github.core.GithubInteractor 7 | import it.codingjam.github.core.OpenForTesting 8 | import it.codingjam.github.core.RepoId 9 | import it.codingjam.github.util.* 10 | import it.codingjam.github.vo.lce 11 | import java.util.* 12 | import javax.inject.Inject 13 | 14 | @OpenForTesting 15 | @FeatureAppScope 16 | class SearchUseCase @Inject constructor( 17 | private val githubInteractor: GithubInteractor, 18 | prefs: SharedPreferences 19 | ) { 20 | 21 | private var lastSearch by prefs.string("") 22 | 23 | fun initialState() = SearchViewState(lastSearch) 24 | 25 | fun setQuery(originalInput: String, state: SearchViewState): ActionsFlow { 26 | lastSearch = originalInput 27 | val input = originalInput.toLowerCase(Locale.getDefault()).trim { it <= ' ' } 28 | return if (state.repos.data?.searchInvoked != true || input != state.query) { 29 | reloadData(input) 30 | } else 31 | emptyActionsFlow() 32 | } 33 | 34 | fun loadNextPage(state: SearchViewState): ActionsFlow = actionsFlow { 35 | state.repos.doOnData { (_, nextPage, _, loadingMore) -> 36 | val query = state.query 37 | if (query.isNotEmpty() && nextPage != null && !loadingMore) { 38 | emit { copy(loadingMore = true) } 39 | try { 40 | val (items, newNextPage) = githubInteractor.searchNextPage(query, nextPage) 41 | emit { 42 | copy(list = list + items, nextPage = newNextPage, loadingMore = false) 43 | } 44 | } catch (t: Exception) { 45 | emit { copy(loadingMore = false) } 46 | emit(ErrorSignal(t)) 47 | } 48 | } 49 | } 50 | }.mapActions { stateAction -> copy(repos = repos.map { stateAction(it) }) } 51 | 52 | private fun reloadData(query: String): ActionsFlow = lce { 53 | val (items, nextPage) = githubInteractor.search(query) 54 | ReposViewState(items, nextPage, true) 55 | }.mapActions { stateAction -> copy(repos = stateAction(repos), query = query) } 56 | 57 | fun refresh(state: SearchViewState): ActionsFlow { 58 | return if (state.query.isNotEmpty()) { 59 | reloadData(state.query) 60 | } else 61 | emptyActionsFlow() 62 | } 63 | 64 | fun openRepoDetail(id: RepoId) = NavigationSignal("repo", id) 65 | } -------------------------------------------------------------------------------- /uisearch/src/main/java/it/codingjam/github/ui/search/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.search 18 | 19 | import androidx.lifecycle.ViewModel 20 | import androidx.lifecycle.viewModelScope 21 | import it.codingjam.github.core.OpenForTesting 22 | import it.codingjam.github.core.RepoId 23 | import it.codingjam.github.util.ViewStateStoreFactory 24 | import javax.inject.Inject 25 | 26 | @OpenForTesting 27 | class SearchViewModel @Inject constructor( 28 | private val searchUseCase: SearchUseCase, 29 | factory: ViewStateStoreFactory 30 | ) : ViewModel() { 31 | 32 | val state = factory(searchUseCase.initialState(), viewModelScope) 33 | 34 | fun setQuery(originalInput: String) = state.dispatchActions(searchUseCase.setQuery(originalInput, state())) 35 | 36 | fun loadNextPage() = state.dispatchActions(searchUseCase.loadNextPage(state())) 37 | 38 | fun refresh() = state.dispatchActions(searchUseCase.refresh(state())) 39 | 40 | fun openRepoDetail(id: RepoId) = state.dispatchSignal(searchUseCase.openRepoDetail(id)) 41 | } 42 | -------------------------------------------------------------------------------- /uisearch/src/main/java/it/codingjam/github/ui/search/SearchViewState.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.search 2 | 3 | import it.codingjam.github.core.Repo 4 | import it.codingjam.github.vo.Lce 5 | 6 | data class ReposViewState( 7 | val list: List, 8 | val nextPage: Int? = null, 9 | val searchInvoked: Boolean = false, 10 | val loadingMore: Boolean = false 11 | ) { 12 | val emptyStateVisible: Boolean = searchInvoked && list.isEmpty() 13 | } 14 | 15 | data class SearchViewState( 16 | val query: String = "", 17 | val repos: Lce = Lce.Success(ReposViewState(emptyList())) 18 | ) { 19 | 20 | val reposState = repos.data 21 | } 22 | -------------------------------------------------------------------------------- /uisearch/src/main/res/layout/search_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 22 | 23 | 24 | 25 | 28 | 29 | 32 | 33 | 34 | 38 | 39 | 46 | 47 | 58 | 59 | 60 | 66 | 67 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /uisearch/src/main/res/layout/search_result.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 20 | 21 | 22 | 25 | 28 | 29 | 30 | 34 | 35 | 45 | 46 | 59 | 60 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /uisearch/src/main/res/navigation/search_nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | -------------------------------------------------------------------------------- /uisearch/src/test/java/it/codingjam/github/ui/search/SearchUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.search 2 | 3 | 4 | import assertk.all 5 | import assertk.assertThat 6 | import assertk.assertions.* 7 | import com.nalulabs.prefs.fake.FakeSharedPreferences 8 | import com.nhaarman.mockitokotlin2.doReturn 9 | import com.nhaarman.mockitokotlin2.doThrow 10 | import com.nhaarman.mockitokotlin2.mock 11 | import it.codingjam.github.core.GithubInteractor 12 | import it.codingjam.github.core.Repo 13 | import it.codingjam.github.core.RepoSearchResponse 14 | import it.codingjam.github.testdata.TestData.REPO_1 15 | import it.codingjam.github.testdata.TestData.REPO_2 16 | import it.codingjam.github.testdata.TestData.REPO_3 17 | import it.codingjam.github.testdata.TestData.REPO_4 18 | import it.codingjam.github.testdata.containsLce 19 | import it.codingjam.github.testdata.map 20 | import it.codingjam.github.testdata.on 21 | import it.codingjam.github.testdata.states 22 | import it.codingjam.github.util.ErrorSignal 23 | import it.codingjam.github.util.Signal 24 | import org.junit.Test 25 | 26 | class SearchUseCaseTest { 27 | private val interactor: GithubInteractor = mock() 28 | private val useCase = SearchUseCase(interactor, FakeSharedPreferences()) 29 | 30 | @Test 31 | fun load() { 32 | on { interactor.search(QUERY) } doReturn RepoSearchResponse(listOf(REPO_1, REPO_2), 2) 33 | 34 | val initialState = SearchViewState() 35 | val states = useCase.setQuery(QUERY, initialState) 36 | .states(initialState) 37 | .filterIsInstance().map { it.repos } 38 | 39 | assertThat(states).containsLce("LS") 40 | 41 | assertThat(states.map { it.data?.emptyStateVisible ?: false }) 42 | .containsExactly(false, false) 43 | 44 | assertThat(states.last().data?.list) 45 | .isNotNull() 46 | .containsExactly(REPO_1, REPO_2) 47 | } 48 | 49 | @Test 50 | fun emptyStateVisible() { 51 | on { interactor.search(QUERY) } doReturn RepoSearchResponse(emptyList(), null) 52 | 53 | val initialState = SearchViewState() 54 | val states = useCase.setQuery(QUERY, initialState) 55 | .states(initialState) 56 | .filterIsInstance().map { it.repos } 57 | 58 | assertThat(states).all { 59 | containsLce("LS") 60 | 61 | map { it.data }.all { 62 | map { it?.emptyStateVisible ?: false } 63 | .containsExactly(false, true) 64 | 65 | transform { it.last()?.list } 66 | .isNotNull().isEmpty() 67 | } 68 | } 69 | } 70 | 71 | private fun response(repo1: Repo, repo2: Repo, nextPage: Int): RepoSearchResponse { 72 | return RepoSearchResponse(listOf(repo1, repo2), nextPage) 73 | } 74 | 75 | @Test 76 | fun loadMore() { 77 | on { interactor.search(QUERY) } doReturn response(REPO_1, REPO_2, 2) 78 | on { interactor.searchNextPage(QUERY, 2) } doReturn response(REPO_3, REPO_4, 3) 79 | 80 | val initialState = SearchViewState() 81 | val lastState = useCase.setQuery(QUERY, initialState) 82 | .states(initialState) 83 | .filterIsInstance().last() 84 | 85 | val states = useCase.loadNextPage(lastState) 86 | .states(lastState) 87 | .filterIsInstance() 88 | 89 | assertThat(states.map { it.repos }).all { 90 | containsLce("SS") 91 | 92 | map { it.data?.loadingMore ?: false } 93 | .containsExactly(true, false) 94 | 95 | transform { it.last().data!!.list } 96 | .isEqualTo(listOf(REPO_1, REPO_2, REPO_3, REPO_4)) 97 | } 98 | 99 | } 100 | 101 | @Test 102 | fun errorLoadingMore() { 103 | on { interactor.search(QUERY) } doReturn response(REPO_1, REPO_2, 2) 104 | on { interactor.searchNextPage(QUERY, 2) } doThrow RuntimeException(ERROR) 105 | 106 | val initialState = SearchViewState() 107 | val lastState = useCase.setQuery(QUERY, initialState) 108 | .states(initialState) 109 | .filterIsInstance().last() 110 | 111 | val states = useCase.loadNextPage(lastState) 112 | .states(lastState) 113 | .filterIsInstance() 114 | 115 | assertThat(states.map { it.repos }).containsLce("SS") 116 | 117 | assertThat(states.map { it.repos.data?.loadingMore ?: false }) 118 | .containsExactly(true, false) 119 | 120 | assertThat(states.last().repos.data?.list).isNotNull().containsExactly(REPO_1, REPO_2) 121 | 122 | val signals = useCase.loadNextPage(lastState) 123 | .states(lastState) 124 | .filterIsInstance() 125 | 126 | assertThat(signals.last()) 127 | .isInstanceOf(ErrorSignal::class) 128 | .prop(ErrorSignal::message) 129 | .isEqualTo(ERROR) 130 | } 131 | 132 | companion object { 133 | private const val QUERY = "query" 134 | private const val ERROR = "error" 135 | } 136 | } -------------------------------------------------------------------------------- /uisearchTest/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /uisearchTest/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | 5 | android { 6 | compileSdkVersion 28 7 | 8 | defaultConfig { 9 | applicationId "it.codingjam.github" 10 | minSdkVersion 16 11 | targetSdkVersion 28 12 | versionCode 1 13 | versionName "1.0" 14 | 15 | testInstrumentationRunner 'it.codingjam.github.espresso.MockTestRunner' 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | } 22 | } 23 | dataBinding { 24 | enabled = true 25 | } 26 | packagingOptions { 27 | exclude 'META-INF/DEPENDENCIES' 28 | exclude 'META-INF/LICENSE' 29 | exclude 'META-INF/LICENSE.txt' 30 | exclude 'META-INF/license.txt' 31 | exclude 'META-INF/NOTICE' 32 | exclude 'META-INF/NOTICE.txt' 33 | exclude 'META-INF/notice.txt' 34 | exclude 'META-INF/ASL2.0' 35 | exclude 'META-INF/main.kotlin_module' 36 | exclude 'META-INF/atomicfu.kotlin_module' 37 | } 38 | } 39 | 40 | dependencies { 41 | implementation project(':uisearch') 42 | 43 | kaptAndroidTest "com.google.dagger:dagger-compiler:$dagger_version" 44 | androidTestImplementation project(':androidTestLib') 45 | androidTestImplementation project(':testData') 46 | } 47 | -------------------------------------------------------------------------------- /uisearchTest/src/androidTest/java/it/codingjam/github/ui/repo/SearchFragmentTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.repo 18 | 19 | import android.os.Bundle 20 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 21 | import androidx.test.core.app.ApplicationProvider 22 | import androidx.test.espresso.Espresso.onView 23 | import androidx.test.espresso.assertion.ViewAssertions.matches 24 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 25 | import androidx.test.espresso.matcher.ViewMatchers.withId 26 | import com.nhaarman.mockitokotlin2.doAnswer 27 | import com.nhaarman.mockitokotlin2.mock 28 | import it.codingjam.github.R 29 | import it.codingjam.github.espresso.FragmentTestRule 30 | import it.codingjam.github.espresso.TestApplication 31 | import it.codingjam.github.testdata.TEST_DISPATCHER 32 | import it.codingjam.github.testdata.TestData.REPO_1 33 | import it.codingjam.github.testdata.TestData.REPO_2 34 | import it.codingjam.github.ui.search.ReposViewState 35 | import it.codingjam.github.ui.search.SearchViewModel 36 | import it.codingjam.github.ui.search.SearchViewState 37 | import it.codingjam.github.util.ViewStateStore 38 | import it.codingjam.github.vo.Lce 39 | import it.cosenonjaviste.daggermock.DaggerMock 40 | import it.cosenonjaviste.daggermock.interceptor 41 | import kotlinx.coroutines.CoroutineScope 42 | import kotlinx.coroutines.Dispatchers 43 | import org.hamcrest.Matchers.not 44 | import org.junit.Before 45 | import org.junit.Rule 46 | import org.junit.Test 47 | 48 | class SearchFragmentTest { 49 | 50 | @get:Rule 51 | val fragmentRule = FragmentTestRule(R.navigation.search_nav_graph, R.id.search) { Bundle() } 52 | 53 | @get:Rule 54 | val instantExecutorRule = InstantTaskExecutorRule() 55 | 56 | private val viewStateStore by lazy { 57 | ViewStateStore(SearchViewState(), CoroutineScope(Dispatchers.Main), TEST_DISPATCHER) 58 | } 59 | 60 | private val viewModel = mock { 61 | on(it.state) doAnswer { viewStateStore } 62 | } 63 | 64 | @Before 65 | fun setUp() { 66 | val app = ApplicationProvider.getApplicationContext() 67 | app.init(DaggerMock.interceptor(this)) 68 | } 69 | 70 | @Test 71 | fun testLoading() { 72 | fragmentRule.launchFragment(Unit) 73 | 74 | viewModel.state.dispatchState(SearchViewState(repos = Lce.Loading)) 75 | 76 | onView(withId(R.id.progress_bar)).check(matches(isDisplayed())) 77 | onView(withId(R.id.retry)).check(matches(not(isDisplayed()))) 78 | } 79 | 80 | @Test 81 | fun testValueWhileLoading() { 82 | fragmentRule.launchFragment(Unit) 83 | 84 | viewModel.state.dispatchState(SearchViewState(repos = Lce.Loading)) 85 | 86 | viewModel.state.dispatchState(SearchViewState(repos = Lce.Success(ReposViewState(listOf(REPO_1, REPO_2))))) 87 | 88 | onView(withId(R.id.progress_bar)).check(matches(not(isDisplayed()))) 89 | } 90 | } -------------------------------------------------------------------------------- /uisearchTest/src/androidTest/java/it/codingjam/github/ui/repo/SearchFragmentViewModelTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.repo 18 | 19 | import android.content.SharedPreferences 20 | import android.os.Bundle 21 | import android.view.KeyEvent 22 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 23 | import androidx.test.core.app.ApplicationProvider 24 | import androidx.test.espresso.Espresso.onView 25 | import androidx.test.espresso.action.ViewActions.pressKey 26 | import androidx.test.espresso.action.ViewActions.typeText 27 | import androidx.test.espresso.assertion.ViewAssertions.matches 28 | import androidx.test.espresso.matcher.ViewMatchers.* 29 | import com.nalulabs.prefs.fake.FakeSharedPreferences 30 | import com.nhaarman.mockitokotlin2.doAnswer 31 | import com.nhaarman.mockitokotlin2.mock 32 | import it.codingjam.github.R 33 | import it.codingjam.github.core.GithubInteractor 34 | import it.codingjam.github.core.RepoSearchResponse 35 | import it.codingjam.github.espresso.FragmentTestRule 36 | import it.codingjam.github.espresso.TestApplication 37 | import it.codingjam.github.testdata.TestData.REPO_1 38 | import it.cosenonjaviste.daggermock.DaggerMock 39 | import it.cosenonjaviste.daggermock.interceptor 40 | import org.hamcrest.Matchers.not 41 | import org.junit.Before 42 | import org.junit.Rule 43 | import org.junit.Test 44 | 45 | class SearchFragmentViewModelTest { 46 | 47 | @get:Rule 48 | var fragmentRule = FragmentTestRule(R.navigation.search_nav_graph, R.id.search) { Bundle() } 49 | 50 | @get:Rule 51 | var instantExecutorRule = InstantTaskExecutorRule() 52 | 53 | val prefs: SharedPreferences = FakeSharedPreferences() 54 | 55 | val githubInteractor = mock { 56 | onBlocking { it.search("abc") } doAnswer { 57 | RepoSearchResponse(listOf(REPO_1), 1) 58 | } 59 | } 60 | 61 | @Before 62 | fun setUp() { 63 | val app = ApplicationProvider.getApplicationContext() 64 | app.init(DaggerMock.interceptor(this)) 65 | } 66 | 67 | @Test 68 | fun testLoad() { 69 | fragmentRule.launchFragment(Unit) 70 | 71 | onView(withId(R.id.input)).perform( 72 | typeText("abc"), pressKey(KeyEvent.KEYCODE_ENTER)) 73 | 74 | onView(withId(R.id.progress_bar)).check(matches(not(isDisplayed()))) 75 | onView(withId(R.id.retry)).check(matches(not(isDisplayed()))) 76 | onView(withText(REPO_1.fullName)).check(matches(isDisplayed())) 77 | } 78 | } -------------------------------------------------------------------------------- /uisearchTest/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /uiuser/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /uiuser/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: "kotlin-allopen" 6 | 7 | allOpen { 8 | annotation("it.codingjam.github.core.AllOpen") 9 | } 10 | 11 | android { 12 | compileSdkVersion 28 13 | 14 | defaultConfig { 15 | minSdkVersion 16 16 | targetSdkVersion 28 17 | versionCode 1 18 | versionName "1.0" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | } 25 | } 26 | 27 | dataBinding { 28 | enabled = true 29 | } 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | } 35 | 36 | dependencies { 37 | api project(':core') 38 | api project(':viewlib') 39 | 40 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 41 | 42 | testImplementation project(':testData') 43 | testImplementation "org.mockito:mockito-inline:$mockito_version" 44 | } 45 | -------------------------------------------------------------------------------- /uiuser/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /uiuser/src/main/java/it/codingjam/github/ui/user/UserFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.user 18 | 19 | import android.os.Bundle 20 | import android.view.LayoutInflater 21 | import android.view.View 22 | import android.view.ViewGroup 23 | import it.codingjam.github.core.RepoId 24 | import it.codingjam.github.core.UserDetail 25 | import it.codingjam.github.ui.common.DataBoundListAdapter 26 | import it.codingjam.github.ui.common.FragmentCreator 27 | import it.codingjam.github.ui.user.databinding.UserFragmentBinding 28 | import it.codingjam.github.util.ErrorSignal 29 | import it.codingjam.github.util.LceContainer 30 | import it.codingjam.github.util.NavigationSignal 31 | import it.codingjam.github.util.viewModel 32 | import it.codingjam.github.viewLibComponent 33 | 34 | class UserFragment : androidx.fragment.app.Fragment() { 35 | 36 | private val navigationController by lazy { 37 | requireActivity().application.viewLibComponent.navigationController 38 | } 39 | 40 | private val viewModel by viewModel { 41 | userFragmentComponent.viewModel.apply { load() } 42 | } 43 | 44 | private lateinit var lceContainer: LceContainer 45 | 46 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 47 | lceContainer = LceContainer(requireContext()) { 48 | viewModel.load() 49 | } 50 | 51 | val adapter = DataBoundListAdapter { UserRepoViewHolder(it, viewModel) } 52 | 53 | val binding = UserFragmentBinding.inflate(inflater, lceContainer, true) 54 | 55 | binding.repoList.adapter = adapter 56 | 57 | lceContainer.setUpdateListener { 58 | binding.user = it.user 59 | adapter.replace(it.repos) 60 | binding.executePendingBindings() 61 | } 62 | 63 | return lceContainer 64 | } 65 | 66 | override fun onActivityCreated(savedInstanceState: Bundle?) { 67 | super.onActivityCreated(savedInstanceState) 68 | 69 | viewModel.state.observe(this) { 70 | lceContainer.lce = it 71 | } 72 | viewModel.state.observeSignals(this) { 73 | when (it) { 74 | is ErrorSignal -> navigationController.showError(requireActivity(), it.message) 75 | is NavigationSignal<*> -> navigationController.navigateToRepo(this, it.params as RepoId) 76 | } 77 | } 78 | 79 | } 80 | 81 | companion object : FragmentCreator(R.navigation.user_nav_graph, R.id.user) 82 | } 83 | -------------------------------------------------------------------------------- /uiuser/src/main/java/it/codingjam/github/ui/user/UserModule.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.user 2 | 3 | import android.app.Application 4 | import androidx.fragment.app.Fragment 5 | import dagger.BindsInstance 6 | import dagger.Component 7 | import it.codingjam.github.* 8 | import it.codingjam.github.core.CoreComponent 9 | import it.codingjam.github.core.coreComponent 10 | import it.codingjam.github.core.utils.ComponentHolder 11 | import it.codingjam.github.core.utils.getOrCreate 12 | 13 | @FeatureAppScope 14 | @Component(dependencies = [ViewLibComponent::class, CoreComponent::class]) 15 | interface UserAppComponent : ViewLibComponent { 16 | val userUseCase: UserUseCase 17 | 18 | @Component.Factory 19 | interface Factory { 20 | fun create(core: CoreComponent, viewLib: ViewLibComponent): UserAppComponent 21 | } 22 | } 23 | 24 | val Application.userComponent 25 | get() = (this as ComponentHolder).getOrCreate { 26 | DaggerUserAppComponent.factory() 27 | .create(coreComponent, viewLibComponent) 28 | } 29 | 30 | @FeatureFragmentScope 31 | @Component(dependencies = [UserAppComponent::class]) 32 | interface UserFragmentComponent { 33 | val viewModel: UserViewModel 34 | 35 | @Component.Factory 36 | interface Factory { 37 | fun create(@BindsInstance param: String, userAppComponent: UserAppComponent): UserFragmentComponent 38 | } 39 | } 40 | 41 | val Fragment.userFragmentComponent: UserFragmentComponent 42 | get() = getOrCreateFragmentComponent { 43 | DaggerUserFragmentComponent.factory() 44 | .create(UserFragment.param(this), requireActivity().application.userComponent) 45 | } -------------------------------------------------------------------------------- /uiuser/src/main/java/it/codingjam/github/ui/user/UserRepoViewHolder.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.user 2 | 3 | 4 | import android.view.ViewGroup 5 | import it.codingjam.github.core.Repo 6 | import it.codingjam.github.ui.common.DataBoundViewHolder 7 | import it.codingjam.github.viewlib.databinding.RepoItemBinding 8 | 9 | class UserRepoViewHolder(parent: ViewGroup, viewModel: UserViewModel) : 10 | DataBoundViewHolder(parent, RepoItemBinding::inflate) { 11 | init { 12 | binding.showFullName = false 13 | binding.root.setOnClickListener { 14 | viewModel.openRepoDetail(item.repoId) 15 | } 16 | } 17 | 18 | override fun bind(t: Repo) { 19 | binding.repo = t 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /uiuser/src/main/java/it/codingjam/github/ui/user/UserUseCase.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.user 2 | 3 | import it.codingjam.github.FeatureAppScope 4 | import it.codingjam.github.core.GithubInteractor 5 | import it.codingjam.github.core.OpenForTesting 6 | import it.codingjam.github.core.RepoId 7 | import it.codingjam.github.util.NavigationSignal 8 | import it.codingjam.github.vo.lce 9 | import javax.inject.Inject 10 | 11 | @OpenForTesting 12 | @FeatureAppScope 13 | class UserUseCase @Inject constructor( 14 | private val githubInteractor: GithubInteractor 15 | ) { 16 | fun load(login: String) = lce { 17 | githubInteractor.loadUserDetail(login) 18 | } 19 | 20 | fun openRepoDetail(id: RepoId) = NavigationSignal("repo", id) 21 | } -------------------------------------------------------------------------------- /uiuser/src/main/java/it/codingjam/github/ui/user/UserViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.user 18 | 19 | import androidx.lifecycle.ViewModel 20 | import androidx.lifecycle.viewModelScope 21 | import it.codingjam.github.core.OpenForTesting 22 | import it.codingjam.github.core.RepoId 23 | import it.codingjam.github.core.UserDetail 24 | import it.codingjam.github.util.ViewStateStoreFactory 25 | import it.codingjam.github.vo.Lce 26 | import javax.inject.Inject 27 | 28 | @OpenForTesting 29 | class UserViewModel @Inject constructor( 30 | private val userUseCase: UserUseCase, 31 | private val login: String, 32 | factory: ViewStateStoreFactory 33 | ) : ViewModel() { 34 | 35 | val state = factory>(Lce.Loading, viewModelScope) 36 | 37 | fun load() = state.dispatchActions(userUseCase.load(login)) 38 | 39 | fun retry() = load() 40 | 41 | fun openRepoDetail(id: RepoId) = state.dispatchSignal(userUseCase.openRepoDetail(id)) 42 | } 43 | -------------------------------------------------------------------------------- /uiuser/src/main/java/it/codingjam/github/ui/user/UserViewState.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.user 2 | 3 | import it.codingjam.github.core.UserDetail 4 | import it.codingjam.github.vo.Lce 5 | 6 | typealias UserViewState = Lce -------------------------------------------------------------------------------- /uiuser/src/main/res/layout/user_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 32 | 33 | 42 | 43 | 52 | 53 | 65 | 66 | 67 | 81 | 82 | -------------------------------------------------------------------------------- /uiuser/src/main/res/navigation/user_nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | -------------------------------------------------------------------------------- /uiuser/src/test/java/it/codingjam/github/ui/user/UserUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.user 18 | 19 | import assertk.assertThat 20 | import assertk.assertions.containsExactly 21 | import assertk.assertions.isEqualTo 22 | import com.nhaarman.mockitokotlin2.doReturn 23 | import com.nhaarman.mockitokotlin2.mock 24 | import it.codingjam.github.core.GithubInteractor 25 | import it.codingjam.github.core.RepoId 26 | import it.codingjam.github.core.UserDetail 27 | import it.codingjam.github.testdata.TestData.REPO_1 28 | import it.codingjam.github.testdata.TestData.REPO_2 29 | import it.codingjam.github.testdata.TestData.USER 30 | import it.codingjam.github.testdata.on 31 | import it.codingjam.github.testdata.states 32 | import it.codingjam.github.vo.Lce 33 | import org.junit.Test 34 | 35 | class UserUseCaseTest { 36 | 37 | private val githubInteractor: GithubInteractor = mock() 38 | private val userUseCase = UserUseCase(githubInteractor) 39 | 40 | @Test 41 | fun load() { 42 | on { githubInteractor.loadUserDetail(LOGIN) } doReturn UserDetail(USER, listOf(REPO_1, REPO_2)) 43 | 44 | val states = userUseCase.load(LOGIN).states(Lce.Loading) 45 | 46 | assertThat(states) 47 | .containsExactly( 48 | Lce.Loading, 49 | Lce.Success(UserDetail(USER, listOf(REPO_1, REPO_2))) 50 | ) 51 | } 52 | 53 | @Test 54 | fun retry() { 55 | on { githubInteractor.loadUserDetail(LOGIN) } 56 | .thenThrow(RuntimeException(ERROR)) 57 | .thenReturn(UserDetail(USER, listOf(REPO_1, REPO_2))) 58 | 59 | val states = userUseCase.load(LOGIN).states(Lce.Loading) + 60 | userUseCase.load(LOGIN).states(Lce.Loading) 61 | 62 | assertThat(states) 63 | .containsExactly( 64 | Lce.Loading, 65 | Lce.Error(ERROR), 66 | Lce.Loading, 67 | Lce.Success(UserDetail(USER, listOf(REPO_1, REPO_2))) 68 | ) 69 | } 70 | 71 | @Test 72 | fun openRepoDetail() { 73 | val (_, params) = userUseCase.openRepoDetail(REPO_ID) 74 | assertThat(params).isEqualTo(REPO_ID) 75 | } 76 | 77 | companion object { 78 | private const val LOGIN = "login" 79 | private const val ERROR = "error" 80 | private val REPO_ID = RepoId("owner", "name") 81 | } 82 | } -------------------------------------------------------------------------------- /uiuserTest/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /uiuserTest/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | 5 | android { 6 | compileSdkVersion 28 7 | 8 | defaultConfig { 9 | applicationId "it.codingjam.github" 10 | minSdkVersion 16 11 | targetSdkVersion 28 12 | versionCode 1 13 | versionName "1.0" 14 | 15 | testInstrumentationRunner 'it.codingjam.github.espresso.MockTestRunner' 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | } 22 | } 23 | dataBinding { 24 | enabled = true 25 | } 26 | packagingOptions { 27 | exclude 'META-INF/DEPENDENCIES' 28 | exclude 'META-INF/LICENSE' 29 | exclude 'META-INF/LICENSE.txt' 30 | exclude 'META-INF/license.txt' 31 | exclude 'META-INF/NOTICE' 32 | exclude 'META-INF/NOTICE.txt' 33 | exclude 'META-INF/notice.txt' 34 | exclude 'META-INF/ASL2.0' 35 | exclude 'META-INF/main.kotlin_module' 36 | exclude 'META-INF/atomicfu.kotlin_module' 37 | } 38 | } 39 | 40 | dependencies { 41 | implementation project(':uiuser') 42 | 43 | kaptAndroidTest "com.google.dagger:dagger-compiler:$dagger_version" 44 | androidTestImplementation project(':androidTestLib') 45 | androidTestImplementation project(':testData') 46 | } 47 | -------------------------------------------------------------------------------- /uiuserTest/src/androidTest/java/it/codingjam/github/ui/user/UserFragmentTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.user 18 | 19 | import android.os.Debug 20 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 21 | import androidx.test.core.app.ApplicationProvider 22 | import androidx.test.espresso.Espresso.onView 23 | import androidx.test.espresso.assertion.ViewAssertions.matches 24 | import androidx.test.espresso.matcher.ViewMatchers.* 25 | import com.nhaarman.mockitokotlin2.doAnswer 26 | import com.nhaarman.mockitokotlin2.mock 27 | import it.codingjam.github.core.UserDetail 28 | import it.codingjam.github.espresso.TestApplication 29 | import it.codingjam.github.espresso.rule 30 | import it.codingjam.github.testdata.TEST_DISPATCHER 31 | import it.codingjam.github.testdata.TestData.REPO_1 32 | import it.codingjam.github.testdata.TestData.REPO_2 33 | import it.codingjam.github.testdata.TestData.USER 34 | import it.codingjam.github.util.ViewStateStore 35 | import it.codingjam.github.vo.Lce 36 | import it.cosenonjaviste.daggermock.DaggerMock 37 | import it.cosenonjaviste.daggermock.interceptor 38 | import kotlinx.coroutines.CoroutineScope 39 | import kotlinx.coroutines.Dispatchers 40 | import org.hamcrest.Matchers.not 41 | import org.junit.Before 42 | import org.junit.Rule 43 | import org.junit.Test 44 | 45 | class UserFragmentTest { 46 | 47 | @get:Rule val fragmentRule = UserFragment.rule() 48 | 49 | @get:Rule val instantExecutorRule = InstantTaskExecutorRule() 50 | 51 | private val viewStateStore by lazy { 52 | ViewStateStore(Lce.Loading, CoroutineScope(Dispatchers.Main), TEST_DISPATCHER) 53 | } 54 | 55 | private val viewModel = mock { 56 | on(it.state) doAnswer { viewStateStore } 57 | } 58 | 59 | @Before 60 | fun setUp() { 61 | val app = ApplicationProvider.getApplicationContext() 62 | app.init(DaggerMock.interceptor(this)) 63 | } 64 | 65 | @Test 66 | fun testLoading() { 67 | Debug.startMethodTracing() 68 | fragmentRule.launchFragment("user") 69 | 70 | viewModel.state.dispatchState(Lce.Loading) 71 | 72 | onView(withId(R.id.progress_bar)).check(matches(isDisplayed())) 73 | onView(withId(R.id.retry)).check(matches(not(isDisplayed()))) 74 | Debug.stopMethodTracing() 75 | } 76 | 77 | @Test 78 | fun testValueWhileLoading() { 79 | fragmentRule.launchFragment("user") 80 | 81 | viewModel.state.dispatchState(Lce.Loading) 82 | viewModel.state.dispatchState(Lce.Success(UserDetail(USER, listOf(REPO_1, REPO_2)))) 83 | 84 | onView(withId(R.id.progress_bar)).check(matches(not(isDisplayed()))) 85 | onView(withId(R.id.user_name)).check(matches(withText(USER.name))) 86 | } 87 | } -------------------------------------------------------------------------------- /uiuserTest/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /viewlib/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /viewlib/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: "kotlin-allopen" 6 | 7 | allOpen { 8 | annotation("it.codingjam.github.core.AllOpen") 9 | } 10 | 11 | android { 12 | compileSdkVersion 28 13 | 14 | defaultConfig { 15 | minSdkVersion 16 16 | targetSdkVersion 28 17 | versionCode 1 18 | versionName "1.0" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | } 25 | } 26 | 27 | dataBinding { 28 | enabled = true 29 | } 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | } 35 | 36 | dependencies { 37 | implementation project(':core') 38 | 39 | api "androidx.constraintlayout:constraintlayout:$constraint_layout_version" 40 | api "com.github.bumptech.glide:glide:$glide_version" 41 | api "androidx.appcompat:appcompat:1.0.2" 42 | api "androidx.recyclerview:recyclerview:$support_version" 43 | api "androidx.cardview:cardview:$support_version" 44 | api "com.google.android.material:material:$support_version" 45 | api "androidx.lifecycle:lifecycle-runtime:$arch_version" 46 | api "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-alpha04" 47 | api "androidx.lifecycle:lifecycle-extensions:$arch_version" 48 | api 'androidx.core:core-ktx:1.0.1' 49 | 50 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 51 | kapt "androidx.lifecycle:lifecycle-compiler:$arch_version" 52 | 53 | api "android.arch.navigation:navigation-fragment-ktx:$nav_version" 54 | api "android.arch.navigation:navigation-ui-ktx:$nav_version" 55 | } 56 | -------------------------------------------------------------------------------- /viewlib/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/ComponentHolderKtx.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github 2 | 3 | import android.app.Application 4 | import androidx.fragment.app.Fragment 5 | import androidx.fragment.app.FragmentActivity 6 | import androidx.lifecycle.Lifecycle 7 | import androidx.lifecycle.LifecycleObserver 8 | import androidx.lifecycle.LifecycleOwner 9 | import androidx.lifecycle.OnLifecycleEvent 10 | import it.codingjam.github.core.utils.ComponentHolder 11 | import it.codingjam.github.core.utils.getOrCreate 12 | 13 | 14 | inline fun ComponentHolder.getOrCreate(lifecycleOwner: LifecycleOwner, noinline componentFactory: () -> C): C { 15 | val key = lifecycleOwner to C::class.java 16 | lifecycleOwner.lifecycle.addObserver(object : LifecycleObserver { 17 | @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) 18 | fun onDestroy() { 19 | remove(key) 20 | } 21 | }) 22 | return getOrCreate(key, C::class.java, componentFactory) 23 | } 24 | 25 | inline fun Application.getOrCreate(noinline componentFactory: () -> C): C = 26 | (this as ComponentHolder).getOrCreate(componentFactory) 27 | 28 | inline fun FragmentActivity.getOrCreateActivityComponent(noinline componentFactory: () -> C): C = 29 | (application as ComponentHolder).getOrCreate(this, componentFactory) 30 | 31 | inline fun Fragment.getOrCreateFragmentComponent(noinline componentFactory: () -> C): C = 32 | (requireActivity().application as ComponentHolder).getOrCreate(this, componentFactory) -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/FeatureAppScope.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github 2 | 3 | import javax.inject.Scope 4 | 5 | @Scope 6 | @Retention 7 | annotation class FeatureAppScope 8 | 9 | @Scope 10 | @Retention 11 | annotation class FeatureFragmentScope -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/NavigationController.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github 18 | 19 | import it.codingjam.github.core.RepoId 20 | 21 | interface NavigationController { 22 | fun navigateToRepo(fragment: androidx.fragment.app.Fragment, repoId: RepoId) 23 | fun navigateToUser(fragment: androidx.fragment.app.Fragment, login: String) 24 | fun showError(activity: androidx.fragment.app.FragmentActivity, error: String?) 25 | } 26 | 27 | -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/ViewLibModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github 18 | 19 | import android.app.Application 20 | import android.content.SharedPreferences 21 | import android.preference.PreferenceManager 22 | import dagger.BindsInstance 23 | import dagger.Component 24 | import dagger.Module 25 | import dagger.Provides 26 | import it.codingjam.github.core.OpenForTesting 27 | import it.codingjam.github.core.utils.ComponentHolder 28 | import it.codingjam.github.core.utils.get 29 | import it.codingjam.github.core.utils.getOrCreate 30 | import it.codingjam.github.util.ViewStateStoreFactory 31 | import kotlinx.coroutines.Dispatchers 32 | import javax.inject.Singleton 33 | 34 | @OpenForTesting 35 | @Module 36 | internal class ViewLibModule { 37 | @Provides 38 | @Singleton 39 | fun providePrefs(application: Application): SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) 40 | 41 | @Provides 42 | @Singleton 43 | fun viewStateStoreFactory() = ViewStateStoreFactory(Dispatchers.IO) 44 | } 45 | 46 | interface ViewLibComponent { 47 | val prefs: SharedPreferences 48 | 49 | val viewStateStoreFactory: ViewStateStoreFactory 50 | 51 | val navigationController: NavigationController 52 | } 53 | 54 | @Component( 55 | modules = [ViewLibModule::class], 56 | dependencies = [ViewLibDependencies::class] 57 | ) 58 | @Singleton 59 | internal interface ViewLibComponentImpl : ViewLibComponent { 60 | @Component.Factory 61 | interface Factory { 62 | fun create(@BindsInstance app: Application, dependencies: ViewLibDependencies): ViewLibComponent 63 | } 64 | } 65 | 66 | interface ViewLibDependencies { 67 | val navigationController: NavigationController 68 | } 69 | 70 | val Application.viewLibComponent 71 | get() = (this as ComponentHolder).getOrCreate { 72 | DaggerViewLibComponentImpl.factory().create( 73 | this, 74 | this.get() 75 | ) 76 | } -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/binding/BindingAdapters.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.binding 18 | 19 | import android.content.Context 20 | import android.view.KeyEvent 21 | import android.view.View 22 | import android.view.View.* 23 | import android.view.inputmethod.EditorInfo 24 | import android.view.inputmethod.InputMethodManager 25 | import android.widget.EditText 26 | import android.widget.ImageView 27 | import android.widget.TextView 28 | import androidx.databinding.BindingAdapter 29 | import com.bumptech.glide.Glide 30 | 31 | 32 | @set:BindingAdapter("visibleOrGone") 33 | var View.visibleOrGone 34 | get() = visibility == VISIBLE 35 | set(value) { 36 | visibility = if (value) VISIBLE else GONE 37 | } 38 | 39 | @set:BindingAdapter("visible") 40 | var View.visible 41 | get() = visibility == VISIBLE 42 | set(value) { 43 | visibility = if (value) VISIBLE else INVISIBLE 44 | } 45 | 46 | @set:BindingAdapter("invisible") 47 | var View.invisible 48 | get() = visibility == INVISIBLE 49 | set(value) { 50 | visibility = if (value) INVISIBLE else VISIBLE 51 | } 52 | 53 | @set:BindingAdapter("gone") 54 | var View.gone 55 | get() = visibility == GONE 56 | set(value) { 57 | visibility = if (value) GONE else VISIBLE 58 | } 59 | 60 | @BindingAdapter("imageUrl") 61 | fun ImageView.setImageUrl(url: String?) { 62 | Glide.with(context).load(url).into(this) 63 | } 64 | 65 | @BindingAdapter("onSearch") 66 | fun onSearch(input: EditText, listener: OnTextListener?) { 67 | input.setOnEditorActionListener({ v, actionId, _ -> 68 | if (actionId == EditorInfo.IME_ACTION_SEARCH) { 69 | if (listener != null) { 70 | val query = input.text.toString() 71 | v.dismissKeyboard() 72 | listener.onChanged(query) 73 | } 74 | true 75 | } else { 76 | false 77 | } 78 | }) 79 | } 80 | 81 | @BindingAdapter("onKeyDown") 82 | fun onKeyDown(input: EditText, listener: OnTextListener?) { 83 | input.setOnKeyListener { _, keyCode, event -> 84 | if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) { 85 | if (listener != null) { 86 | val query = input.text.toString() 87 | input.dismissKeyboard() 88 | listener.onChanged(query) 89 | } 90 | true 91 | } else { 92 | false 93 | } 94 | } 95 | } 96 | 97 | interface OnTextListener { 98 | fun onChanged(s: String) 99 | } 100 | 101 | fun TextView.dismissKeyboard() { 102 | if (context != null) { 103 | val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 104 | imm.hideSoftInputFromWindow(windowToken, 0) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/ui/common/DataBoundListAdapter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.common 18 | 19 | import android.view.ViewGroup 20 | import androidx.databinding.ViewDataBinding 21 | 22 | class DataBoundListAdapter( 23 | private var factory: (ViewGroup) -> DataBoundViewHolder 24 | ) : androidx.recyclerview.widget.RecyclerView.Adapter>() { 25 | 26 | private var items: List = emptyList() 27 | 28 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataBoundViewHolder = factory(parent) 29 | 30 | override fun onBindViewHolder(holder: DataBoundViewHolder, position: Int) { 31 | val item = items[position] 32 | holder.item = item 33 | holder.bind(item) 34 | holder.binding.executePendingBindings() 35 | } 36 | 37 | fun replace(update: List) { 38 | this.items = update 39 | notifyDataSetChanged() 40 | } 41 | 42 | override fun getItemCount(): Int = items.size 43 | } 44 | -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/ui/common/DataBoundViewHolder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.ui.common 18 | 19 | import android.view.LayoutInflater 20 | import android.view.ViewGroup 21 | import androidx.databinding.ViewDataBinding 22 | 23 | abstract class DataBoundViewHolder 24 | private constructor(val binding: V) : 25 | androidx.recyclerview.widget.RecyclerView.ViewHolder(binding.root) { 26 | 27 | constructor(parent: ViewGroup, factory: (LayoutInflater, ViewGroup, Boolean) -> V) : 28 | this(factory(LayoutInflater.from(parent.context), parent, false)) 29 | 30 | lateinit var item: T 31 | 32 | abstract fun bind(t: T) 33 | } 34 | -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/ui/common/FragmentCreator.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.ui.common 2 | 3 | import android.os.Bundle 4 | import android.os.Parcelable 5 | import androidx.annotation.IdRes 6 | import androidx.core.os.bundleOf 7 | import androidx.fragment.app.Fragment 8 | import androidx.navigation.fragment.findNavController 9 | 10 | const val FRAGMENT_CREATOR_PARAM = "param" 11 | 12 | open class FragmentCreator( 13 | @IdRes val graphId: Int, 14 | @IdRes val nodeId: Int 15 | ) { 16 | @Suppress("UNCHECKED_CAST") 17 | val Fragment.param: T 18 | get() = arguments!!.get(FRAGMENT_CREATOR_PARAM) as T 19 | 20 | fun param(fragment: Fragment): T { 21 | @Suppress("UNCHECKED_CAST") 22 | return fragment.arguments!!.get(FRAGMENT_CREATOR_PARAM) as T 23 | } 24 | } 25 | 26 | fun FragmentCreator.args(param: T): Bundle { 27 | return bundleOf(FRAGMENT_CREATOR_PARAM to param) 28 | } 29 | 30 | fun FragmentCreator.navigate(fragment: Fragment, param: T) { 31 | fragment.findNavController().navigate(nodeId, Bundle().apply { 32 | putParcelable(FRAGMENT_CREATOR_PARAM, param) 33 | }) 34 | } 35 | 36 | fun FragmentCreator.navigate(fragment: Fragment, param: String) { 37 | fragment.findNavController().navigate(nodeId, Bundle().apply { 38 | putString(FRAGMENT_CREATOR_PARAM, param) 39 | }) 40 | } -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/util/Action.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.util 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.FlowCollector 5 | import kotlinx.coroutines.flow.emptyFlow 6 | import kotlinx.coroutines.flow.map 7 | 8 | sealed class Action 9 | 10 | class StateAction(private val f: T.() -> T) : Action() { 11 | operator fun invoke(t: T) = t.f() 12 | } 13 | 14 | abstract class Signal : Action() 15 | 16 | class ActionsFlowCollector(private val innerCollector: (FlowCollector>)) { 17 | 18 | suspend fun emit(action: T.() -> T) = 19 | innerCollector.emit(StateAction(action)) 20 | 21 | suspend fun emit(signal: Signal) = 22 | innerCollector.emit(signal) 23 | } 24 | 25 | fun actionsFlow(block: suspend ActionsFlowCollector.() -> Unit): ActionsFlow { 26 | return ActionsFlow(object : Flow> { 27 | override suspend fun collect(collector: FlowCollector>) { 28 | ActionsFlowCollector(collector).block() 29 | } 30 | }) 31 | } 32 | 33 | private val EmptyActionsFlow = ActionsFlow(emptyFlow()) 34 | 35 | fun emptyActionsFlow(): ActionsFlow = EmptyActionsFlow 36 | 37 | class ActionsFlow(private val flow: Flow>) : Flow> by flow 38 | 39 | fun ActionsFlow.mapActions(copy: S.(StateAction) -> S): ActionsFlow = 40 | ActionsFlow(map { action: Action -> action.map(copy) }) 41 | 42 | fun Action.map(copy: S.(StateAction) -> S): Action { 43 | return if (this is Signal) { 44 | this 45 | } else { 46 | val stateAction = this as StateAction 47 | StateAction { copy(stateAction) } 48 | } 49 | } -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/util/ErrorSignal.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.util 2 | 3 | data class ErrorSignal(val error: Throwable?, val message: String) : Signal() { 4 | constructor(t: Throwable) : this(t, t.message ?: "Error ${t.javaClass.name}") 5 | } -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/util/EventsLiveData.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.util 2 | 3 | import android.util.Log 4 | import androidx.annotation.MainThread 5 | import androidx.lifecycle.LifecycleOwner 6 | import androidx.lifecycle.MutableLiveData 7 | import androidx.lifecycle.Observer 8 | 9 | /** 10 | * This class is similar to SingleLiveEvent but it caches all the values when the live data is not active 11 | */ 12 | class EventsLiveData { 13 | 14 | private val liveData = MutableLiveData>() 15 | 16 | private var cache = emptyList() 17 | 18 | @MainThread 19 | fun observe(owner: LifecycleOwner, observer: (T) -> Unit) { 20 | if (liveData.hasActiveObservers()) { 21 | Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") 22 | } 23 | 24 | // Observe the internal MutableLiveData 25 | liveData.observe(owner, Observer { 26 | cache.forEach { 27 | observer(it) 28 | } 29 | cache = emptyList() 30 | }) 31 | } 32 | 33 | @MainThread 34 | fun addEvent(t: T) { 35 | cache = cache + t 36 | liveData.value = cache 37 | } 38 | 39 | companion object { 40 | private const val TAG = "EventsLiveData" 41 | } 42 | } -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/util/LceContainer.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.util 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.widget.FrameLayout 8 | import android.widget.TextView 9 | import androidx.core.view.children 10 | import it.codingjam.github.binding.visibleOrGone 11 | import it.codingjam.github.viewlib.R 12 | import it.codingjam.github.vo.Lce 13 | 14 | class LceContainer : FrameLayout { 15 | 16 | private var loading: View 17 | private var error: View 18 | private var errorMessage: TextView 19 | private var retry: View 20 | 21 | var lce: Lce? = null 22 | set(value) { 23 | when (value) { 24 | is Lce.Loading -> children.forEach { it.visibleOrGone = it == loading } 25 | is Lce.Success -> { 26 | children.forEach { it.visibleOrGone = it != loading && it != error } 27 | updateListener?.invoke(value.data) 28 | } 29 | is Lce.Error -> { 30 | children.forEach { it.visibleOrGone = it == error } 31 | errorMessage.text = value.message 32 | } 33 | } 34 | } 35 | 36 | private var updateListener: ((T) -> Unit)? = null 37 | 38 | fun setRetryAction(retryAction: Runnable?) { 39 | retry.setOnClickListener { retryAction?.run() } 40 | } 41 | 42 | fun setUpdateListener(listener: ((T) -> Unit)) { 43 | updateListener = listener 44 | } 45 | 46 | constructor(context: Context) : super(context) 47 | 48 | constructor(context: Context, retryAction: () -> Unit) : super(context) { 49 | retry.setOnClickListener { retryAction() } 50 | } 51 | 52 | constructor(context: Context, attrs: AttributeSet?) : super(context, attrs, 0) 53 | 54 | init { 55 | val inflater = LayoutInflater.from(context) 56 | loading = inflater.inflate(R.layout.loading, this, false).also { addView(it) } 57 | error = inflater.inflate(R.layout.error, this, false).also { addView(it) } 58 | errorMessage = error.findViewById(R.id.error_message) 59 | retry = error.findViewById(R.id.retry) 60 | } 61 | } -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/util/NavigationSignal.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.util 2 | 3 | data class NavigationSignal

(val destination: Any, val params: P) : Signal() -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/util/ViewModelUtils.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.util 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.fragment.app.FragmentActivity 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.ViewModelProvider 7 | import androidx.lifecycle.ViewModelProviders 8 | 9 | 10 | inline fun Fragment.viewModel(crossinline provider: () -> VM): Lazy { 11 | return lazy { 12 | val factory = object : ViewModelProvider.Factory { 13 | override fun create(aClass: Class): T1 { 14 | val viewModel = provider.invoke() 15 | return viewModel as T1 16 | } 17 | } 18 | ViewModelProviders.of(this, factory).get(VM::class.java) 19 | } 20 | } 21 | 22 | inline fun FragmentActivity.viewModel(crossinline provider: () -> VM): Lazy { 23 | return lazy { 24 | val factory = object : ViewModelProvider.Factory { 25 | override fun create(aClass: Class): T1 { 26 | val viewModel = provider.invoke() 27 | return viewModel as T1 28 | } 29 | } 30 | ViewModelProviders.of(this, factory).get(VM::class.java) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/util/ViewStateStore.kt: -------------------------------------------------------------------------------- 1 | package it.codingjam.github.util 2 | 3 | 4 | import androidx.lifecycle.LifecycleOwner 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.Observer 7 | import kotlinx.coroutines.* 8 | import kotlinx.coroutines.flow.collect 9 | import kotlinx.coroutines.flow.flowOn 10 | 11 | class ViewStateStoreFactory( 12 | private val dispatcher: CoroutineDispatcher 13 | ) { 14 | operator fun invoke(initialState: T, scope: CoroutineScope) = 15 | ViewStateStore(initialState, scope, dispatcher) 16 | } 17 | 18 | class ViewStateStore( 19 | initialState: T, 20 | private val scope: CoroutineScope, 21 | private val dispatcher: CoroutineDispatcher 22 | ) { 23 | 24 | private val stateLiveData = MutableLiveData().apply { 25 | value = initialState 26 | } 27 | 28 | private val signalsLiveData = EventsLiveData() 29 | 30 | fun observe(owner: LifecycleOwner, observer: (T) -> Unit) = 31 | stateLiveData.observe(owner, Observer { observer(it!!) }) 32 | 33 | fun observeSignals(owner: LifecycleOwner, observer: (Signal) -> Unit) = 34 | signalsLiveData.observe(owner) { observer(it) } 35 | 36 | fun dispatchState(state: T) { 37 | scope.launch(Dispatchers.Main.immediate) { 38 | stateLiveData.value = state 39 | } 40 | } 41 | 42 | fun dispatchSignal(action: Signal) { 43 | signalsLiveData.addEvent(action) 44 | } 45 | 46 | private fun dispatch(action: Action) { 47 | if (action is StateAction) { 48 | stateLiveData.value = action(invoke()) 49 | } else if (action is Signal) { 50 | signalsLiveData.addEvent(action) 51 | } 52 | } 53 | 54 | fun dispatchAction(f: suspend () -> Action) { 55 | // dispatchActions(flow { emit(f()) }) 56 | scope.launch { 57 | val action = withContext(dispatcher) { 58 | f() 59 | } 60 | dispatch(action) 61 | } 62 | } 63 | 64 | fun dispatchActions(flow: ActionsFlow) { 65 | scope.launch { 66 | flow 67 | .flowOn(dispatcher) 68 | .collect { action -> 69 | dispatch(action) 70 | } 71 | } 72 | } 73 | 74 | operator fun invoke() = stateLiveData.value!! 75 | } -------------------------------------------------------------------------------- /viewlib/src/main/java/it/codingjam/github/vo/Lce.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package it.codingjam.github.vo 18 | 19 | import it.codingjam.github.util.ActionsFlow 20 | import it.codingjam.github.util.actionsFlow 21 | 22 | sealed class Lce { 23 | 24 | open val data: T? = null 25 | 26 | abstract fun map(f: (T) -> R): Lce 27 | 28 | inline fun doOnData(f: (T) -> Unit) { 29 | if (this is Success) { 30 | f(data) 31 | } 32 | } 33 | 34 | data class Success(override val data: T) : Lce() { 35 | override fun map(f: (T) -> R): Lce = Success(f(data)) 36 | } 37 | 38 | data class Error(val message: String) : Lce() { 39 | constructor(t: Throwable) : this(t.message ?: "") 40 | 41 | override fun map(f: (Nothing) -> R): Lce = this 42 | } 43 | 44 | object Loading : Lce() { 45 | override fun map(f: (Nothing) -> R): Lce = this 46 | } 47 | } 48 | 49 | inline fun lce(crossinline f: suspend () -> S): ActionsFlow> { 50 | return actionsFlow { 51 | emit { Lce.Loading } 52 | try { 53 | val result = f() 54 | emit { Lce.Success(result) } 55 | } catch (e: Exception) { 56 | emit { Lce.Error(e) } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /viewlib/src/main/res/layout/error.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 25 | 26 | 35 | 36 |