├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── 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 │ │ │ ├── drawable-hdpi │ │ │ │ ├── ic_no_characters.webp │ │ │ │ └── starwars_wallpaper.webp │ │ │ ├── drawable-mdpi │ │ │ │ ├── ic_no_characters.webp │ │ │ │ └── starwars_wallpaper.webp │ │ │ ├── drawable-xhdpi │ │ │ │ ├── ic_no_characters.webp │ │ │ │ └── starwars_wallpaper.webp │ │ │ ├── drawable-xxhdpi │ │ │ │ ├── ic_no_characters.webp │ │ │ │ └── starwars_wallpaper.webp │ │ │ ├── drawable-xxxhdpi │ │ │ │ ├── ic_no_characters.webp │ │ │ │ └── starwars_wallpaper.webp │ │ │ ├── drawable │ │ │ │ ├── background_input.xml │ │ │ │ ├── background_gradient.xml │ │ │ │ ├── ic_baseline_clear_24.xml │ │ │ │ ├── ic_baseline_search_24.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ ├── themes.xml │ │ │ │ └── strings.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── layout │ │ │ │ ├── item_character.xml │ │ │ │ ├── item_movie.xml │ │ │ │ ├── item_specie.xml │ │ │ │ ├── activity_search_characters.xml │ │ │ │ └── activity_details_characters.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── hb │ │ │ │ └── stars │ │ │ │ ├── domain │ │ │ │ ├── models │ │ │ │ │ ├── PlanetModel.kt │ │ │ │ │ ├── SpecieModel.kt │ │ │ │ │ ├── MovieModel.kt │ │ │ │ │ └── CharacterModel.kt │ │ │ │ ├── usecases │ │ │ │ │ ├── GetPlanetUseCase.kt │ │ │ │ │ ├── GetMoviesUseCase.kt │ │ │ │ │ ├── GetSpeciesUseCase.kt │ │ │ │ │ └── SearchCharactersUseCase.kt │ │ │ │ └── repository │ │ │ │ │ └── StarWarsRepository.kt │ │ │ │ ├── data │ │ │ │ ├── response │ │ │ │ │ ├── DomainMapper.kt │ │ │ │ │ ├── ListCharacterResponse.kt │ │ │ │ │ ├── PlanetResponse.kt │ │ │ │ │ ├── SpecieResponse.kt │ │ │ │ │ ├── MovieResponse.kt │ │ │ │ │ └── CharacterResponse.kt │ │ │ │ ├── commun │ │ │ │ │ ├── Config.kt │ │ │ │ │ ├── CoroutineHelper.kt │ │ │ │ │ ├── FlowHelper.kt │ │ │ │ │ ├── DataSourceException.kt │ │ │ │ │ ├── StarWarsResult.kt │ │ │ │ │ └── RequestErrorHandler.kt │ │ │ │ ├── datasource │ │ │ │ │ └── remote │ │ │ │ │ │ ├── StarWarsDataSource.kt │ │ │ │ │ │ ├── StarWarsServices.kt │ │ │ │ │ │ └── StarWarsDataSourceImpl.kt │ │ │ │ └── repository │ │ │ │ │ └── StarWarsRepositoryImpl.kt │ │ │ │ ├── utils │ │ │ │ ├── Constants.kt │ │ │ │ ├── ConnectionLiveData.kt │ │ │ │ └── Extensions.kt │ │ │ │ ├── ui │ │ │ │ ├── search │ │ │ │ │ ├── SearchCharactersViewModel.kt │ │ │ │ │ ├── CharactersAdapter.kt │ │ │ │ │ └── SearchCharactersActivity.kt │ │ │ │ └── details │ │ │ │ │ ├── SpeciesAdapter.kt │ │ │ │ │ ├── MoviesAdapter.kt │ │ │ │ │ ├── DetailsCharactersViewModel.kt │ │ │ │ │ └── DetailsCharactersActivity.kt │ │ │ │ ├── di │ │ │ │ ├── viewmodel │ │ │ │ │ ├── ViewModelKey.kt │ │ │ │ │ └── DaggerViewModelFactory.kt │ │ │ │ ├── component │ │ │ │ │ └── AppComponent.kt │ │ │ │ └── module │ │ │ │ │ ├── RepositoriesModule.kt │ │ │ │ │ ├── ViewModelModule.kt │ │ │ │ │ └── NetworkModule.kt │ │ │ │ └── StarWarsApplication.kt │ │ └── AndroidManifest.xml │ ├── test │ │ ├── java │ │ │ └── com │ │ │ │ └── hb │ │ │ │ └── stars │ │ │ │ ├── helpers │ │ │ │ ├── Utils.kt │ │ │ │ └── MainCoroutineRule.kt │ │ │ │ ├── mapper │ │ │ │ ├── PlanetMapperTest.kt │ │ │ │ ├── MovieMapperTest.kt │ │ │ │ ├── SpecieMapperTest.kt │ │ │ │ └── CharacterMapperTest.kt │ │ │ │ ├── utils │ │ │ │ └── ExtensionFunctionsTest.kt │ │ │ │ ├── viewModels │ │ │ │ ├── search │ │ │ │ │ └── SearchCharactersViewModelTest.kt │ │ │ │ └── details │ │ │ │ │ └── DetailsCharactersViewModelTest.kt │ │ │ │ ├── datasource │ │ │ │ └── StarWarsDataSourceImplTest.kt │ │ │ │ └── repository │ │ │ │ └── StarWarsRepositoryImplTest.kt │ │ └── resources │ │ │ ├── specie.json │ │ │ ├── planet.json │ │ │ ├── list_characters.json │ │ │ └── movie.json │ └── androidTest │ │ └── java │ │ └── com │ │ └── hb │ │ └── stars │ │ ├── DetailsCharactersActivityTests.kt │ │ └── SearchCharactersActivityTests.kt ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── screenshots ├── Screenshot_1.jpg ├── Screenshot_2.jpg ├── Screenshot_3.jpg └── dataFlowDiagram.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── spotless.gradle ├── gradle.properties └── README.md /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | rootProject.name = "Stars" -------------------------------------------------------------------------------- /screenshots/Screenshot_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/screenshots/Screenshot_1.jpg -------------------------------------------------------------------------------- /screenshots/Screenshot_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/screenshots/Screenshot_2.jpg -------------------------------------------------------------------------------- /screenshots/Screenshot_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/screenshots/Screenshot_3.jpg -------------------------------------------------------------------------------- /screenshots/dataFlowDiagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/screenshots/dataFlowDiagram.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/models/PlanetModel.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.models 2 | 3 | data class PlanetModel(val population: String) 4 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_no_characters.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-hdpi/ic_no_characters.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/starwars_wallpaper.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-hdpi/starwars_wallpaper.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_no_characters.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-mdpi/ic_no_characters.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/starwars_wallpaper.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-mdpi/starwars_wallpaper.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_no_characters.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-xhdpi/ic_no_characters.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_no_characters.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-xxhdpi/ic_no_characters.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/starwars_wallpaper.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-xhdpi/starwars_wallpaper.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/starwars_wallpaper.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-xxhdpi/starwars_wallpaper.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_no_characters.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-xxxhdpi/ic_no_characters.webp -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/models/SpecieModel.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.models 2 | 3 | data class SpecieModel(val name: String, val language: String) 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/starwars_wallpaper.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HamdiBoumaiza/Stars/HEAD/app/src/main/res/drawable-xxxhdpi/starwars_wallpaper.webp -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/response/DomainMapper.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.response 2 | 3 | interface DomainMapper { 4 | fun mapToDomainModel(): T 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/commun/Config.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.commun 2 | 3 | const val BASE_URL = "https://swapi.dev/api/" 4 | const val GET_SEARCH_CHARACTERS_URL = "people/" 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/models/MovieModel.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.models 2 | 3 | data class MovieModel(val title: String, val description: String, var isExpanded: Boolean) 4 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/utils/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.utils 2 | 3 | const val CHARACTER_EXTRA = "character" 4 | const val UNDEFINED = "Undefined" 5 | 6 | const val DEFAULT_MAX_LINES_MOVIE = 3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | 12 | *.log 13 | 14 | gradlew 15 | 16 | *.bat -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/response/ListCharacterResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class ListCharacterResponse( 6 | @SerializedName("results") val results: List? 7 | ) 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jan 25 17:40:57 WAT 2021 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-6.5-bin.zip 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/background_input.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/usecases/GetPlanetUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.usecases 2 | 3 | import com.hb.stars.domain.repository.StarWarsRepository 4 | import javax.inject.Inject 5 | 6 | open class GetPlanetUseCase @Inject constructor( 7 | private val starWarsRepository: StarWarsRepository 8 | ) { 9 | suspend operator fun invoke(planetUrl: String) = starWarsRepository.getPlanet(planetUrl) 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/usecases/GetMoviesUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.usecases 2 | 3 | import com.hb.stars.domain.repository.StarWarsRepository 4 | import javax.inject.Inject 5 | 6 | open class GetMoviesUseCase @Inject constructor( 7 | private val starWarsRepository: StarWarsRepository 8 | ) { 9 | suspend operator fun invoke(movieUrls: List) = starWarsRepository.getMovies(movieUrls) 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/usecases/GetSpeciesUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.usecases 2 | 3 | import com.hb.stars.domain.repository.StarWarsRepository 4 | import javax.inject.Inject 5 | 6 | open class GetSpeciesUseCase @Inject constructor( 7 | private val starWarsRepository: StarWarsRepository 8 | ) { 9 | suspend operator fun invoke(specieUrl: List) = starWarsRepository.getSpecies(specieUrl) 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/usecases/SearchCharactersUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.usecases 2 | 3 | import com.hb.stars.domain.repository.StarWarsRepository 4 | import javax.inject.Inject 5 | 6 | open class SearchCharactersUseCase @Inject constructor( 7 | private val starWarsRepository: StarWarsRepository 8 | ) { 9 | suspend operator fun invoke(input: String) = starWarsRepository.searchCharacters(input) 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/response/PlanetResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import com.hb.stars.domain.models.PlanetModel 5 | import com.hb.stars.utils.UNDEFINED 6 | 7 | data class PlanetResponse( 8 | @SerializedName("population") val population: String? 9 | ) : DomainMapper { 10 | override fun mapToDomainModel() = PlanetModel(population ?: UNDEFINED) 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/background_gradient.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/ui/search/SearchCharactersViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.ui.search 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.hb.stars.domain.usecases.SearchCharactersUseCase 5 | import javax.inject.Inject 6 | 7 | class SearchCharactersViewModel @Inject constructor(private val searchCharactersUseCase: SearchCharactersUseCase) : 8 | ViewModel() { 9 | 10 | suspend fun searchCharacters(input: String) = searchCharactersUseCase(input) 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/di/viewmodel/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.di.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | @MustBeDocumented 8 | @Target( 9 | AnnotationTarget.FUNCTION, 10 | AnnotationTarget.PROPERTY_GETTER, 11 | AnnotationTarget.PROPERTY_SETTER 12 | ) 13 | @Retention(AnnotationRetention.RUNTIME) 14 | @MapKey 15 | annotation class ViewModelKey(val value: KClass) 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_clear_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/helpers/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.helpers 2 | 3 | 4 | import com.google.common.io.Resources.getResource 5 | import java.io.File 6 | 7 | /** 8 | * Helper function which will load JSON from 9 | * the path specified 10 | * 11 | * @param path : Path of JSON file 12 | * @return json : JSON from file at given path 13 | */ 14 | 15 | internal fun getJson(path: String): String { 16 | val uri = getResource(path) 17 | val file = File(uri.path) 18 | return String(file.readBytes()) 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/response/SpecieResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import com.hb.stars.domain.models.SpecieModel 5 | import com.hb.stars.utils.UNDEFINED 6 | 7 | data class SpecieResponse( 8 | @SerializedName("name") val name: String?, 9 | @SerializedName("language") val language: String? 10 | ) : DomainMapper { 11 | override fun mapToDomainModel() = SpecieModel(name ?: UNDEFINED, language ?: UNDEFINED) 12 | } 13 | -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/mapper/PlanetMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.mapper 2 | 3 | import com.google.common.truth.Truth 4 | import com.hb.stars.data.response.PlanetResponse 5 | import org.junit.Test 6 | 7 | class PlanetMapperTest { 8 | private val planetResponse = PlanetResponse( 9 | population = "120000" 10 | ) 11 | private val planetModel = planetResponse.mapToDomainModel() 12 | 13 | @Test 14 | fun planetModelMapperTest() { 15 | Truth.assertThat(planetModel.population).matches("12000") 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/response/MovieResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import com.hb.stars.domain.models.MovieModel 5 | import com.hb.stars.utils.UNDEFINED 6 | 7 | data class MovieResponse( 8 | @SerializedName("title") val title: String?, 9 | @SerializedName("opening_crawl") val description: String? 10 | ) : DomainMapper { 11 | override fun mapToDomainModel() = MovieModel(title ?: UNDEFINED, description 12 | ?: UNDEFINED, false) 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/commun/CoroutineHelper.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.commun 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Deferred 5 | import kotlinx.coroutines.async 6 | 7 | /** 8 | * Example found in the travel adviser app 9 | * https://github.com/RimGazzeh/TravelAdvisor/blob/master/data/src/main/java/com/rim/data/common/CoroutineHelper.kt 10 | **/ 11 | 12 | fun CoroutineScope.asyncAll(list: List, block: suspend (T) -> V): List> { 13 | return list.map { 14 | async { block.invoke(it) } 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/commun/FlowHelper.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.commun 2 | 3 | import com.hb.stars.R 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.catch 6 | import kotlinx.coroutines.flow.onStart 7 | 8 | /** 9 | * extension function for Flow Class to emit loading state before the flow starts 10 | */ 11 | fun Flow>.onFlowStarts() = 12 | onStart { 13 | emit(StarWarsResult.Loading) 14 | }.catch { 15 | emit(StarWarsResult.Error(DataSourceException.Unexpected(R.string.error_unexpected_message))) 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_search_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/utils/ExtensionFunctionsTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.utils 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | 6 | class ExtensionFunctionsTest { 7 | 8 | @Test 9 | fun verifyUrlConversion() { 10 | assertThat(("http://www.google.com").convertUrlToHttps()).matches("https://www.google.com") 11 | } 12 | 13 | @Test 14 | fun verifyCmToFeetConversion() { 15 | assertThat(("170").convertCmToFeet()).matches("5' 7''") 16 | } 17 | 18 | @Test 19 | fun verifyHasValue() { 20 | assertThat(("Undefined").hasValue()).isFalse() 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | 11 | #7a7574 12 | #cae3d1 13 | #298b9b 14 | #21717e 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/commun/DataSourceException.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.commun 2 | 3 | sealed class DataSourceException( 4 | val messageResource: Any? 5 | ) : RuntimeException() { 6 | 7 | class Connection(messageResource: Int) : DataSourceException(messageResource) 8 | 9 | class Unexpected(messageResource: Int) : DataSourceException(messageResource) 10 | 11 | class Timeout(messageResource: Int) : DataSourceException(messageResource) 12 | 13 | class Client(messageResource: Int) : DataSourceException(messageResource) 14 | 15 | class Server(messageResource: Any?) : DataSourceException(messageResource) 16 | } 17 | -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/mapper/MovieMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.mapper 2 | 3 | import com.google.common.truth.Truth 4 | import com.hb.stars.data.response.MovieResponse 5 | import com.hb.stars.utils.UNDEFINED 6 | import org.junit.Test 7 | 8 | class MovieMapperTest { 9 | private val movieResponse = MovieResponse( 10 | title = "The last one", 11 | description = "" 12 | 13 | ) 14 | private val movieModel = movieResponse.mapToDomainModel() 15 | 16 | @Test 17 | fun movieModelMapperTest() { 18 | Truth.assertThat(movieModel.title).matches("Luke Sky") 19 | Truth.assertThat(movieModel.description).matches(UNDEFINED) 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/models/CharacterModel.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.models 2 | 3 | import android.os.Parcelable 4 | import com.hb.stars.utils.convertUrlToHttps 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | data class CharacterModel( 9 | val name: String, 10 | val birthYear: String, 11 | val height: String, 12 | val homeWorld: String, 13 | val films: List, 14 | val species: List 15 | ) : Parcelable { 16 | fun getPlanetUrl() = homeWorld.convertUrlToHttps() 17 | fun getMoviesUrl() = films.map { it.convertUrlToHttps() } 18 | fun getSpeciesUrl() = species.map { it.convertUrlToHttps() } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/di/component/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.di.component 2 | 3 | import com.hb.stars.di.module.NetworkModule 4 | import com.hb.stars.di.module.RepositoriesModule 5 | import com.hb.stars.di.module.ViewModelModule 6 | import com.hb.stars.ui.details.DetailsCharactersActivity 7 | import com.hb.stars.ui.search.SearchCharactersActivity 8 | import dagger.Component 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | @Component( 13 | modules = [ViewModelModule::class, RepositoriesModule::class, NetworkModule::class] 14 | ) 15 | interface AppComponent { 16 | fun inject(searchCharactersActivity: SearchCharactersActivity) 17 | fun inject(detailsCharactersActivity: DetailsCharactersActivity) 18 | } -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/mapper/SpecieMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.mapper 2 | 3 | import com.google.common.truth.Truth 4 | import com.hb.stars.data.response.SpecieResponse 5 | import org.junit.Test 6 | 7 | class SpecieMapperTest { 8 | private val specieResponse = SpecieResponse( 9 | name = "Luke Sky", 10 | language = "lang" 11 | ) 12 | private val specieModel = specieResponse.mapToDomainModel() 13 | 14 | @Test 15 | fun carModelMapperTest() { 16 | Truth.assertThat(specieModel.name).isNotEmpty() 17 | Truth.assertThat(specieModel.name).matches("Luke Sky") 18 | Truth.assertThat(specieModel.language).isNotNull() 19 | Truth.assertThat(specieModel.language).matches("lang") 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/StarWarsApplication.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars 2 | 3 | import android.app.Application 4 | import com.facebook.stetho.Stetho 5 | import com.hb.stars.di.component.AppComponent 6 | import com.hb.stars.di.component.DaggerAppComponent 7 | 8 | open class StarWarsApplication : Application() { 9 | 10 | companion object { 11 | lateinit var appComponent: AppComponent 12 | } 13 | 14 | override fun onCreate() { 15 | super.onCreate() 16 | initDI() 17 | initStetho() 18 | } 19 | 20 | private fun initDI() { 21 | appComponent = DaggerAppComponent.builder().build() 22 | } 23 | 24 | private fun initStetho() { 25 | if (BuildConfig.DEBUG) Stetho.initializeWithDefaults(this) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/test/resources/specie.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mirialan", 3 | "classification": "mammal", 4 | "designation": "sentient", 5 | "average_height": "180", 6 | "skin_colors": "yellow, green", 7 | "hair_colors": "black, brown", 8 | "eye_colors": "blue, green, red, yellow, brown, orange", 9 | "average_lifespan": "unknown", 10 | "homeworld": "http://swapi.dev/api/planets/51/", 11 | "language": "Mirialan", 12 | "people": [ 13 | "http://swapi.dev/api/people/64/", 14 | "http://swapi.dev/api/people/65/" 15 | ], 16 | "films": [ 17 | "http://swapi.dev/api/films/5/", 18 | "http://swapi.dev/api/films/6/" 19 | ], 20 | "created": "2014-12-20T16:46:48.290000Z", 21 | "edited": "2014-12-20T21:36:42.197000Z", 22 | "url": "http://swapi.dev/api/species/29/" 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/di/module/RepositoriesModule.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.di.module 2 | 3 | import com.hb.stars.data.datasource.remote.StarWarsDataSourceImpl 4 | import com.hb.stars.data.datasource.remote.StarWarsServices 5 | import com.hb.stars.data.repository.StarWarsRepositoryImpl 6 | import com.hb.stars.domain.repository.StarWarsRepository 7 | import dagger.Module 8 | import dagger.Provides 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | class RepositoriesModule { 13 | 14 | @Provides 15 | @Singleton 16 | fun provideAppRepository( 17 | api: StarWarsServices 18 | ): StarWarsRepository { 19 | val starWarsDataSourceImpl = StarWarsDataSourceImpl(api) 20 | return StarWarsRepositoryImpl(starWarsDataSourceImpl) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/datasource/remote/StarWarsDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.datasource.remote 2 | 3 | import com.hb.stars.data.commun.StarWarsResult 4 | import com.hb.stars.data.response.CharacterResponse 5 | import com.hb.stars.data.response.MovieResponse 6 | import com.hb.stars.data.response.PlanetResponse 7 | import com.hb.stars.data.response.SpecieResponse 8 | 9 | interface StarWarsDataSource { 10 | suspend fun searchCharacters(input: String): StarWarsResult?> 11 | suspend fun getPlanet(planetUrl: String): StarWarsResult 12 | suspend fun getSpecies(specieUrls: List): StarWarsResult> 13 | suspend fun getMovies(movieUrls: List): StarWarsResult> 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/domain/repository/StarWarsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.domain.repository 2 | 3 | import com.hb.stars.data.commun.StarWarsResult 4 | import com.hb.stars.domain.models.CharacterModel 5 | import com.hb.stars.domain.models.MovieModel 6 | import com.hb.stars.domain.models.PlanetModel 7 | import com.hb.stars.domain.models.SpecieModel 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | interface StarWarsRepository { 11 | 12 | suspend fun searchCharacters(input: String): Flow>> 13 | suspend fun getPlanet(planetUrl: String): Flow> 14 | suspend fun getSpecies(specieUrl: List): Flow>> 15 | suspend fun getMovies(movieUrl: List): Flow>> 16 | } 17 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/commun/StarWarsResult.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.commun 2 | 3 | sealed class StarWarsResult { 4 | data class Success(val data: T) : StarWarsResult() 5 | data class Error(val exception: DataSourceException) : StarWarsResult() 6 | object Loading : StarWarsResult() 7 | } 8 | 9 | inline fun StarWarsResult.onSuccess(action: (T) -> Unit): StarWarsResult { 10 | if (this is StarWarsResult.Success) action(data) 11 | return this 12 | } 13 | 14 | inline fun StarWarsResult.onError(action: (DataSourceException) -> Unit): StarWarsResult { 15 | if (this is StarWarsResult.Error) action(exception) 16 | return this 17 | } 18 | 19 | inline fun StarWarsResult.onLoading(action: () -> Unit): StarWarsResult { 20 | if (this is StarWarsResult.Loading) action() 21 | return this 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_character.xml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | 22 | -------------------------------------------------------------------------------- /spotless.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "com.diffplug.gradle.spotless" 2 | 3 | spotless { 4 | java { 5 | target '**/*.java' 6 | googleJavaFormat().aosp() 7 | removeUnusedImports() 8 | trimTrailingWhitespace() 9 | indentWithSpaces() 10 | endWithNewline() 11 | } 12 | kotlin { 13 | target '**/*.kt' 14 | ktlint() 15 | trimTrailingWhitespace() 16 | indentWithSpaces() 17 | endWithNewline() 18 | } 19 | format 'misc', { 20 | target '**/*.gradle', '**/*.md', '**/.gitignore' 21 | indentWithSpaces() 22 | trimTrailingWhitespace() 23 | endWithNewline() 24 | } 25 | 26 | format 'xml', { 27 | target '**/*.xml' 28 | indentWithSpaces() 29 | trimTrailingWhitespace() 30 | endWithNewline() 31 | } 32 | } 33 | 34 | //Before committing your code, run ./gradlew app:spotlessApply to automatically format your code. -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/di/viewmodel/DaggerViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.di.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import javax.inject.Inject 6 | import javax.inject.Provider 7 | 8 | @Suppress("UNCHECKED_CAST") 9 | class DaggerViewModelFactory @Inject constructor(private val viewModelsMap: Map, @JvmSuppressWildcards Provider>) : 10 | ViewModelProvider.Factory { 11 | 12 | override fun create(modelClass: Class): T { 13 | val creator = viewModelsMap[modelClass] ?: viewModelsMap.asIterable().firstOrNull { 14 | modelClass.isAssignableFrom(it.key) 15 | }?.value ?: throw IllegalArgumentException("unknown model class $modelClass") 16 | return try { 17 | creator.get() as T 18 | } catch (e: Exception) { 19 | throw RuntimeException(e) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/datasource/remote/StarWarsServices.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.datasource.remote 2 | 3 | import com.hb.stars.data.commun.GET_SEARCH_CHARACTERS_URL 4 | import com.hb.stars.data.response.ListCharacterResponse 5 | import com.hb.stars.data.response.MovieResponse 6 | import com.hb.stars.data.response.PlanetResponse 7 | import com.hb.stars.data.response.SpecieResponse 8 | import retrofit2.Response 9 | import retrofit2.http.GET 10 | import retrofit2.http.Query 11 | import retrofit2.http.Url 12 | 13 | interface StarWarsServices { 14 | 15 | @GET(GET_SEARCH_CHARACTERS_URL) 16 | suspend fun searchCharacters(@Query("search") input: String): Response 17 | 18 | @GET 19 | suspend fun getPlanet(@Url planetUrl: String): Response 20 | 21 | @GET 22 | suspend fun getSpecie(@Url specieUrl: String): Response 23 | 24 | @GET 25 | suspend fun getMovie(@Url movieUrl: String): Response 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/response/CharacterResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import com.hb.stars.domain.models.CharacterModel 5 | import com.hb.stars.utils.UNDEFINED 6 | 7 | data class CharacterResponse( 8 | @SerializedName("name") val name: String?, 9 | @SerializedName("birth_year") val birthYear: String?, 10 | @SerializedName("height") val height: String?, 11 | @SerializedName("homeworld") val homeWorld: String?, 12 | @SerializedName("films") val films: List?, 13 | @SerializedName("species") val species: List? 14 | ) : DomainMapper { 15 | override fun mapToDomainModel() = CharacterModel( 16 | name ?: UNDEFINED, 17 | birthYear ?: UNDEFINED, 18 | height ?: UNDEFINED, 19 | homeWorld ?: UNDEFINED, 20 | films ?: emptyList(), 21 | species ?: emptyList() 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/di/module/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.di.module 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.hb.stars.di.viewmodel.DaggerViewModelFactory 6 | import com.hb.stars.di.viewmodel.ViewModelKey 7 | import com.hb.stars.ui.details.DetailsCharactersViewModel 8 | import com.hb.stars.ui.search.SearchCharactersViewModel 9 | import dagger.Binds 10 | import dagger.Module 11 | import dagger.multibindings.IntoMap 12 | 13 | @Module 14 | abstract class ViewModelModule { 15 | 16 | @Binds 17 | @IntoMap 18 | @ViewModelKey(SearchCharactersViewModel::class) 19 | abstract fun bindSearchVM(searchCharactersViewModel: SearchCharactersViewModel): ViewModel 20 | 21 | @Binds 22 | @IntoMap 23 | @ViewModelKey(DetailsCharactersViewModel::class) 24 | abstract fun bindDetailsVM(detailsCharactersViewModel: DetailsCharactersViewModel): ViewModel 25 | 26 | @Binds 27 | abstract fun bindViewModelFactory(factory: DaggerViewModelFactory): ViewModelProvider.Factory 28 | } 29 | -------------------------------------------------------------------------------- /app/src/test/resources/planet.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tatooine", 3 | "rotation_period": "23", 4 | "orbital_period": "304", 5 | "diameter": "10465", 6 | "climate": "arid", 7 | "gravity": "1 standard", 8 | "terrain": "desert", 9 | "surface_water": "1", 10 | "population": "200000", 11 | "residents": [ 12 | "http://swapi.dev/api/people/1/", 13 | "http://swapi.dev/api/people/2/", 14 | "http://swapi.dev/api/people/4/", 15 | "http://swapi.dev/api/people/6/", 16 | "http://swapi.dev/api/people/7/", 17 | "http://swapi.dev/api/people/8/", 18 | "http://swapi.dev/api/people/9/", 19 | "http://swapi.dev/api/people/11/", 20 | "http://swapi.dev/api/people/43/", 21 | "http://swapi.dev/api/people/62/" 22 | ], 23 | "films": [ 24 | "http://swapi.dev/api/films/1/", 25 | "http://swapi.dev/api/films/3/", 26 | "http://swapi.dev/api/films/4/", 27 | "http://swapi.dev/api/films/5/", 28 | "http://swapi.dev/api/films/6/" 29 | ], 30 | "created": "2014-12-09T13:50:49.641000Z", 31 | "edited": "2014-12-20T20:58:18.411000Z", 32 | "url": "http://swapi.dev/api/planets/1/" 33 | } -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 18 | 19 | 20 | 25 | -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/helpers/MainCoroutineRule.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.helpers 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineDispatcher 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.runBlockingTest 8 | import kotlinx.coroutines.test.setMain 9 | import org.junit.rules.TestWatcher 10 | import org.junit.runner.Description 11 | 12 | @ExperimentalCoroutinesApi 13 | class MainCoroutineRule( 14 | val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() 15 | ) : TestWatcher() { 16 | 17 | override fun starting(description: Description?) { 18 | super.starting(description) 19 | Dispatchers.setMain(testDispatcher) 20 | } 21 | 22 | override fun finished(description: Description?) { 23 | super.finished(description) 24 | Dispatchers.resetMain() 25 | testDispatcher.cleanupTestCoroutines() 26 | } 27 | } 28 | 29 | @ExperimentalCoroutinesApi 30 | fun MainCoroutineRule.runBlockingTest(block: suspend () -> Unit) = 31 | this.testDispatcher.runBlockingTest { block() } -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/mapper/CharacterMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.mapper 2 | 3 | import com.google.common.truth.Truth 4 | import com.hb.stars.data.response.CharacterResponse 5 | import com.hb.stars.domain.models.CharacterModel 6 | import com.hb.stars.utils.UNDEFINED 7 | import org.junit.Test 8 | 9 | class CharacterMapperTest { 10 | private val characterResponse = CharacterResponse( 11 | name = "Luke Sky", 12 | birthYear = null, 13 | height = null, 14 | homeWorld = null, 15 | films = listOf(), 16 | species = listOf("url") 17 | 18 | ) 19 | private val characterModel: CharacterModel = characterResponse.mapToDomainModel() 20 | 21 | @Test 22 | fun characterModelMapperTest() { 23 | Truth.assertThat(characterModel.name).matches("Luke Sky") 24 | Truth.assertThat(characterModel.height).isNotNull() 25 | Truth.assertThat(characterModel.birthYear).matches(UNDEFINED) 26 | Truth.assertThat(characterModel.homeWorld).isNotEmpty() 27 | Truth.assertThat(characterModel.films).hasSize(0) 28 | Truth.assertThat(characterModel.species.size).isAtLeast(1) 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 17 | 18 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Stars 3 | 4 | 5 | Please check your Internet Connection. 6 | Something went wrong, please try again. 7 | 8 | 9 | Search characters 10 | Hey There 11 | Let\'s find out more about Star wars characters 12 | No Results 13 | Try typing a name of a star wars characters 14 | 15 | 16 | %s\'s planet population is %s 17 | Movies this character appeared in 18 | Name 19 | Height 20 | Birth Year 21 | Language 22 | Species of this character 23 | %s / %s 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/ui/details/SpeciesAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.ui.details 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import com.hb.stars.databinding.ItemSpecieBinding 7 | import com.hb.stars.domain.models.SpecieModel 8 | 9 | 10 | class SpeciesAdapter(private val list: List) : 11 | RecyclerView.Adapter() { 12 | 13 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( 14 | ItemSpecieBinding.inflate( 15 | LayoutInflater.from(parent.context), 16 | parent, 17 | false 18 | ) 19 | ) 20 | 21 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 22 | holder.bindTo(list[position]) 23 | } 24 | 25 | 26 | override fun getItemCount() = list.size 27 | 28 | inner class ViewHolder(private val view: ItemSpecieBinding) : 29 | RecyclerView.ViewHolder(view.root) { 30 | fun bindTo(specie: SpecieModel) { 31 | with(view) { 32 | itemNameValue.text = specie.name 33 | itemLanguageValue.text = specie.language 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/hb/stars/DetailsCharactersActivityTests.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars 2 | 3 | import androidx.test.espresso.Espresso.onView 4 | import androidx.test.espresso.assertion.ViewAssertions.matches 5 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 6 | import androidx.test.espresso.matcher.ViewMatchers.withId 7 | import androidx.test.ext.junit.rules.ActivityScenarioRule 8 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 9 | import com.hb.stars.ui.details.DetailsCharactersActivity 10 | import com.schibsted.spain.barista.assertion.BaristaVisibilityAssertions.assertDisplayed 11 | import org.hamcrest.CoreMatchers.not 12 | import org.junit.Rule 13 | import org.junit.Test 14 | import org.junit.runner.RunWith 15 | 16 | @RunWith(AndroidJUnit4ClassRunner::class) 17 | class DetailsCharactersActivityTests { 18 | 19 | @get:Rule 20 | val activityRule = ActivityScenarioRule(DetailsCharactersActivity::class.java) 21 | 22 | @Test 23 | fun verifyViews() { 24 | assertDisplayed(R.id.tv_details_name, R.string.name) 25 | assertDisplayed(R.id.tv_details_height, R.string.height) 26 | assertDisplayed(R.id.tv_details_birth_year, R.string.birth_year) 27 | } 28 | 29 | @Test 30 | fun testViewsVisibilityOnAppLaunch() { 31 | onView(withId(R.id.card_movies)).check(matches(not(isDisplayed()))) 32 | onView(withId(R.id.card_species)).check(matches(not(isDisplayed()))) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/ui/details/MoviesAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.ui.details 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import com.hb.stars.databinding.ItemMovieBinding 7 | import com.hb.stars.domain.models.MovieModel 8 | import com.hb.stars.utils.DEFAULT_MAX_LINES_MOVIE 9 | 10 | 11 | class MoviesAdapter(private val list: List) : RecyclerView.Adapter() { 12 | 13 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( 14 | ItemMovieBinding.inflate( 15 | LayoutInflater.from(parent.context), 16 | parent, 17 | false 18 | ) 19 | ) 20 | 21 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 22 | holder.bindTo(list[position]) 23 | } 24 | 25 | override fun getItemCount() = list.size 26 | 27 | inner class ViewHolder(private val view: ItemMovieBinding) : 28 | RecyclerView.ViewHolder(view.root) { 29 | 30 | init { 31 | view.root.setOnClickListener { 32 | val expanded: Boolean = list[adapterPosition].isExpanded 33 | list[adapterPosition].isExpanded = !expanded 34 | notifyItemChanged(adapterPosition) 35 | } 36 | } 37 | 38 | fun bindTo(movie: MovieModel) { 39 | with(view) { 40 | itemTitle.text = movie.title 41 | itemDescription.text = movie.description 42 | if (movie.isExpanded) itemDescription.maxLines = Integer.MAX_VALUE 43 | else itemDescription.maxLines = DEFAULT_MAX_LINES_MOVIE 44 | 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/ui/search/CharactersAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.ui.search 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.DiffUtil 6 | import androidx.recyclerview.widget.ListAdapter 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.hb.stars.databinding.ItemCharacterBinding 9 | import com.hb.stars.domain.models.CharacterModel 10 | 11 | 12 | class CharactersAdapter( 13 | private val action: (CharacterModel) -> Unit 14 | ) : 15 | ListAdapter(CharactersDiffCallback()) { 16 | 17 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( 18 | ItemCharacterBinding.inflate( 19 | LayoutInflater.from(parent.context), 20 | parent, 21 | false 22 | ) 23 | ) 24 | 25 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 26 | holder.bindTo(getItem(position)) 27 | } 28 | 29 | 30 | inner class ViewHolder(private val view: ItemCharacterBinding) : 31 | RecyclerView.ViewHolder(view.root) { 32 | 33 | init { 34 | view.root.setOnClickListener { action(getItem(adapterPosition)) } 35 | } 36 | 37 | fun bindTo(character: CharacterModel) { 38 | view.itemTitle.text = character.name 39 | } 40 | } 41 | } 42 | 43 | class CharactersDiffCallback : DiffUtil.ItemCallback() { 44 | override fun areItemsTheSame(oldItem: CharacterModel, newItem: CharacterModel): Boolean { 45 | return oldItem.name == newItem.name 46 | } 47 | 48 | override fun areContentsTheSame(oldItem: CharacterModel, newItem: CharacterModel): Boolean { 49 | return oldItem == newItem 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/di/module/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.di.module 2 | 3 | import com.facebook.stetho.okhttp3.StethoInterceptor 4 | import com.hb.stars.BuildConfig 5 | import com.hb.stars.data.commun.BASE_URL 6 | import com.hb.stars.data.datasource.remote.StarWarsServices 7 | import dagger.Module 8 | import dagger.Provides 9 | import okhttp3.OkHttpClient 10 | import retrofit2.Retrofit 11 | import retrofit2.converter.gson.GsonConverterFactory 12 | import java.util.concurrent.TimeUnit 13 | import javax.inject.Singleton 14 | 15 | @Module 16 | class NetworkModule { 17 | companion object { 18 | const val IO_TIMEOUT = 30L 19 | } 20 | 21 | @Provides 22 | @Singleton 23 | fun providesRetrofit( 24 | gsonConverterFactory: GsonConverterFactory, 25 | okHttpClient: OkHttpClient 26 | ): Retrofit { 27 | return Retrofit.Builder().baseUrl(BASE_URL) 28 | .addConverterFactory(gsonConverterFactory) 29 | .client(okHttpClient) 30 | .build() 31 | } 32 | 33 | @Provides 34 | @Singleton 35 | fun providesOkHttpClient(): OkHttpClient { 36 | val client = OkHttpClient.Builder() 37 | .connectTimeout(IO_TIMEOUT, TimeUnit.SECONDS) 38 | .writeTimeout(IO_TIMEOUT, TimeUnit.SECONDS) 39 | .readTimeout(IO_TIMEOUT, TimeUnit.SECONDS) 40 | 41 | if (BuildConfig.DEBUG) 42 | client.addNetworkInterceptor(StethoInterceptor()) 43 | return client.build() 44 | } 45 | 46 | @Provides 47 | @Singleton 48 | fun providesConverterFactory(): GsonConverterFactory = GsonConverterFactory.create() 49 | 50 | @Provides 51 | @Singleton 52 | fun provideApiService(retrofit: Retrofit): StarWarsServices = 53 | retrofit.create(StarWarsServices::class.java) 54 | } 55 | -------------------------------------------------------------------------------- /app/src/test/resources/list_characters.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 2, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "name": "Luke Skywalker", 8 | "height": "172", 9 | "mass": "77", 10 | "hair_color": "blond", 11 | "skin_color": "fair", 12 | "eye_color": "blue", 13 | "birth_year": "19BBY", 14 | "gender": "male", 15 | "homeworld": "http://swapi.dev/api/planets/1/", 16 | "films": [ 17 | "http://swapi.dev/api/films/1/", 18 | "http://swapi.dev/api/films/2/", 19 | "http://swapi.dev/api/films/3/", 20 | "http://swapi.dev/api/films/6/" 21 | ], 22 | "species": [], 23 | "vehicles": [ 24 | "http://swapi.dev/api/vehicles/14/", 25 | "http://swapi.dev/api/vehicles/30/" 26 | ], 27 | "starships": [ 28 | "http://swapi.dev/api/starships/12/", 29 | "http://swapi.dev/api/starships/22/" 30 | ], 31 | "created": "2014-12-09T13:50:51.644000Z", 32 | "edited": "2014-12-20T21:17:56.891000Z", 33 | "url": "http://swapi.dev/api/people/1/" 34 | }, 35 | { 36 | "name": "Luminara Unduli", 37 | "height": "170", 38 | "mass": "56.2", 39 | "hair_color": "black", 40 | "skin_color": "yellow", 41 | "eye_color": "blue", 42 | "birth_year": "58BBY", 43 | "gender": "female", 44 | "homeworld": "http://swapi.dev/api/planets/51/", 45 | "films": [ 46 | "http://swapi.dev/api/films/5/", 47 | "http://swapi.dev/api/films/6/" 48 | ], 49 | "species": [ 50 | "http://swapi.dev/api/species/29/" 51 | ], 52 | "vehicles": [], 53 | "starships": [], 54 | "created": "2014-12-20T16:45:53.668000Z", 55 | "edited": "2014-12-20T21:17:50.455000Z", 56 | "url": "http://swapi.dev/api/people/64/" 57 | } 58 | ] 59 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/commun/RequestErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.commun 2 | 3 | import com.hb.stars.R 4 | import retrofit2.HttpException 5 | import java.io.IOException 6 | import java.net.SocketTimeoutException 7 | 8 | object RequestErrorHandler { 9 | 10 | private const val HTTP_CODE_CLIENT_START = 400 11 | private const val HTTP_CODE_CLIENT_END = 499 12 | private const val HTTP_CODE_SERVER_START = 500 13 | private const val HTTP_CODE_SERVER_END = 599 14 | 15 | fun getRequestError(throwable: Throwable): DataSourceException { 16 | return when (throwable) { 17 | is HttpException -> { 18 | handleHttpException(throwable) 19 | } 20 | is IOException -> { 21 | DataSourceException.Connection(R.string.error_network) 22 | } 23 | is SocketTimeoutException -> { 24 | // TODO update message 25 | DataSourceException.Timeout(R.string.error_unexpected_message) 26 | } 27 | else -> { 28 | DataSourceException.Unexpected(R.string.error_unexpected_message) 29 | } 30 | } 31 | } 32 | 33 | private fun handleHttpException(httpException: HttpException): DataSourceException { 34 | return when (httpException.code()) { 35 | in HTTP_CODE_CLIENT_START..HTTP_CODE_CLIENT_END -> { 36 | // TODO update message 37 | DataSourceException.Client(R.string.error_unexpected_message) 38 | } 39 | in HTTP_CODE_SERVER_START..HTTP_CODE_SERVER_END -> { 40 | // TODO update message 41 | DataSourceException.Server(R.string.error_unexpected_message) 42 | } 43 | else -> { 44 | DataSourceException.Unexpected(R.string.error_unexpected_message) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/utils/ConnectionLiveData.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.utils 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.IntentFilter 7 | import android.net.ConnectivityManager 8 | import android.net.ConnectivityManager.CONNECTIVITY_ACTION 9 | import android.net.NetworkCapabilities 10 | import androidx.lifecycle.LiveData 11 | 12 | class ConnectionLiveData(private val context: Context) : LiveData() { 13 | 14 | private val networkReceiver = object : BroadcastReceiver() { 15 | override fun onReceive(context: Context, intent: Intent) { 16 | if (intent.extras != null) { 17 | if (isNetworkConnected()) { 18 | postValue(true) 19 | } else { 20 | postValue(false) 21 | } 22 | } 23 | } 24 | } 25 | 26 | private fun isNetworkConnected(): Boolean { 27 | val connectivityManager = 28 | context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 29 | val network = connectivityManager.activeNetwork 30 | if (network != null) { 31 | val networkCapabilities = connectivityManager.getNetworkCapabilities(network)!! 32 | return networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || 33 | networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) 34 | } 35 | return false 36 | } 37 | 38 | @Suppress("DEPRECATION") 39 | override fun onActive() { 40 | super.onActive() 41 | val filter = IntentFilter(CONNECTIVITY_ACTION) 42 | context.registerReceiver(networkReceiver, filter) 43 | } 44 | 45 | override fun onInactive() { 46 | super.onInactive() 47 | context.unregisterReceiver(networkReceiver) 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/item_movie.xml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | 15 | 28 | 29 | 30 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/viewModels/search/SearchCharactersViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.viewModels.search 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import com.google.common.truth.Truth.assertThat 5 | import com.hb.stars.data.commun.onError 6 | import com.hb.stars.data.commun.onSuccess 7 | import com.hb.stars.datasource.StarWarsDataSourceImplTest 8 | import com.hb.stars.domain.usecases.SearchCharactersUseCase 9 | import com.hb.stars.helpers.MainCoroutineRule 10 | import com.hb.stars.helpers.runBlockingTest 11 | import com.hb.stars.ui.search.SearchCharactersViewModel 12 | import com.hb.stars.repository.StarWarsRepositoryImplTest 13 | import kotlinx.coroutines.ExperimentalCoroutinesApi 14 | import org.junit.Before 15 | import org.junit.Rule 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.junit.runners.JUnit4 19 | 20 | @RunWith(JUnit4::class) 21 | class SearchCharactersViewModelTest { 22 | 23 | @get:Rule 24 | var instantTaskExecutorRule = InstantTaskExecutorRule() 25 | 26 | @ExperimentalCoroutinesApi 27 | @get:Rule 28 | var mainCoroutineRule = MainCoroutineRule() 29 | 30 | private lateinit var viewModel: SearchCharactersViewModel 31 | 32 | @Before 33 | fun setup() { 34 | viewModel = SearchCharactersViewModel( 35 | SearchCharactersUseCase( 36 | StarWarsRepositoryImplTest( 37 | StarWarsDataSourceImplTest() 38 | ) 39 | ) 40 | ) 41 | } 42 | 43 | @ExperimentalCoroutinesApi 44 | @Test 45 | fun `get list characters , returns success`() = mainCoroutineRule.runBlockingTest { 46 | viewModel.searchCharacters("lu") 47 | 48 | viewModel.resultListCharacters.value?.onSuccess { result -> 49 | assertThat(result).isNotNull() 50 | assertThat(result[0].name).isEqualTo("Luke Skywalker") 51 | assertThat(result.size).isAtLeast(2) 52 | assertThat(result[1].height).matches("170") 53 | }?.onError { error -> 54 | assertThat(error).isNull() 55 | } 56 | 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/ui/details/DetailsCharactersViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.ui.details 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.hb.stars.data.commun.StarWarsResult 6 | import com.hb.stars.domain.models.MovieModel 7 | import com.hb.stars.domain.models.PlanetModel 8 | import com.hb.stars.domain.models.SpecieModel 9 | import com.hb.stars.domain.usecases.GetMoviesUseCase 10 | import com.hb.stars.domain.usecases.GetPlanetUseCase 11 | import com.hb.stars.domain.usecases.GetSpeciesUseCase 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.collect 15 | import kotlinx.coroutines.launch 16 | import javax.inject.Inject 17 | 18 | class DetailsCharactersViewModel @Inject constructor( 19 | private val getMovieUseCase: GetMoviesUseCase, 20 | private val getPlanetUseCase: GetPlanetUseCase, 21 | private val getSpecieUseCase: GetSpeciesUseCase 22 | ) : ViewModel() { 23 | 24 | private val _resultMovie = MutableStateFlow>>(StarWarsResult.Loading) 25 | val resultMovie: StateFlow>> = _resultMovie 26 | 27 | fun getMovies(movieUrls: List) { 28 | viewModelScope.launch { 29 | getMovieUseCase(movieUrls).collect { 30 | _resultMovie.emit(it) 31 | } 32 | } 33 | } 34 | 35 | private val _resultSpecie = MutableStateFlow>>(StarWarsResult.Loading) 36 | val resultSpecie: StateFlow>> = _resultSpecie 37 | 38 | fun getSpecies(movieUrls: List) { 39 | viewModelScope.launch { 40 | getSpecieUseCase(movieUrls).collect { 41 | _resultSpecie.emit(it) 42 | } 43 | } 44 | } 45 | 46 | private val _resultPlanet = MutableStateFlow>(StarWarsResult.Loading) 47 | val resultPlanet: StateFlow> = _resultPlanet 48 | 49 | fun getPlanet(movieUrl: String) { 50 | viewModelScope.launch { 51 | getPlanetUseCase(movieUrl).collect { 52 | _resultPlanet.emit(it) 53 | } 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/datasource/StarWarsDataSourceImplTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.datasource 2 | 3 | import com.google.gson.Gson 4 | import com.hb.stars.R 5 | import com.hb.stars.data.commun.DataSourceException 6 | import com.hb.stars.data.commun.StarWarsResult 7 | import com.hb.stars.data.datasource.remote.StarWarsDataSource 8 | import com.hb.stars.data.response.* 9 | import com.hb.stars.helpers.getJson 10 | import com.hb.stars.utils.fromJsonToObjectType 11 | 12 | class StarWarsDataSourceImplTest : StarWarsDataSource { 13 | 14 | override suspend fun searchCharacters(input: String): StarWarsResult?> { 15 | val result = 16 | Gson().fromJsonToObjectType(getJson("list_characters.json")) 17 | return if (result != null) { 18 | StarWarsResult.Success(result.results) 19 | } else { 20 | StarWarsResult.Error(DataSourceException.Unexpected(R.string.error_unexpected_message)) 21 | } 22 | } 23 | 24 | override suspend fun getPlanet(planetUrl: String): StarWarsResult { 25 | val result = 26 | Gson().fromJsonToObjectType(getJson("planet.json")) 27 | return if (result != null) { 28 | StarWarsResult.Success(result) 29 | } else { 30 | StarWarsResult.Error(DataSourceException.Unexpected(R.string.error_unexpected_message)) 31 | } 32 | } 33 | 34 | override suspend fun getSpecies(specieUrl: String): StarWarsResult { 35 | val result = 36 | Gson().fromJsonToObjectType(getJson("specie.json")) 37 | return if (result != null) { 38 | StarWarsResult.Success(result) 39 | } else { 40 | StarWarsResult.Error(DataSourceException.Unexpected(R.string.error_unexpected_message)) 41 | } 42 | } 43 | 44 | override suspend fun getMovie(movieUrl: String): StarWarsResult { 45 | val result = 46 | Gson().fromJsonToObjectType(getJson("movie.json")) 47 | return if (result != null) { 48 | StarWarsResult.Success(result) 49 | } else { 50 | StarWarsResult.Error(DataSourceException.Unexpected(R.string.error_unexpected_message)) 51 | } 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/hb/stars/SearchCharactersActivityTests.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars 2 | 3 | import androidx.test.espresso.Espresso.onView 4 | import androidx.test.espresso.assertion.ViewAssertions.matches 5 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 6 | import androidx.test.espresso.matcher.ViewMatchers.withId 7 | import androidx.test.ext.junit.rules.ActivityScenarioRule 8 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 9 | import com.hb.stars.ui.search.SearchCharactersActivity 10 | import com.schibsted.spain.barista.assertion.BaristaHintAssertions.assertHint 11 | import com.schibsted.spain.barista.assertion.BaristaImageViewAssertions.assertHasDrawable 12 | import com.schibsted.spain.barista.assertion.BaristaListAssertions.assertDisplayedAtPosition 13 | import com.schibsted.spain.barista.assertion.BaristaVisibilityAssertions.assertDisplayed 14 | import com.schibsted.spain.barista.assertion.BaristaVisibilityAssertions.assertNotDisplayed 15 | import com.schibsted.spain.barista.interaction.BaristaClickInteractions.clickOn 16 | import com.schibsted.spain.barista.interaction.BaristaEditTextInteractions.writeTo 17 | import com.schibsted.spain.barista.interaction.BaristaListInteractions 18 | import java.lang.Thread.sleep 19 | import org.hamcrest.CoreMatchers.not 20 | import org.junit.Rule 21 | import org.junit.Test 22 | import org.junit.runner.RunWith 23 | 24 | @RunWith(AndroidJUnit4ClassRunner::class) 25 | class SearchCharactersActivityTests { 26 | 27 | @get:Rule 28 | val activityRule = ActivityScenarioRule(SearchCharactersActivity::class.java) 29 | 30 | @Test 31 | fun verifyViews() { 32 | assertHasDrawable(R.id.img_background, R.drawable.starwars_wallpaper) 33 | assertDisplayed(R.id.tv_title, R.string.hey_there) 34 | assertNotDisplayed(R.id.img_no_character_found) 35 | assertHint(R.id.et_search, R.string.search_characters) 36 | clickOn(R.id.et_search) 37 | } 38 | 39 | @Test 40 | fun testViewsVisibility() { 41 | onView(withId(R.id.rv_characters)).check(matches(isDisplayed())) 42 | onView(withId(R.id.progress_circular)).check(matches(not(isDisplayed()))) 43 | } 44 | 45 | @Test 46 | fun testListListenerAndRecyclerView() { 47 | // onView(withId(R.id.et_search)).perform(clearText(), typeText("lu")) 48 | writeTo(R.id.et_search, "lu") 49 | sleep(4000) 50 | assertDisplayedAtPosition(R.id.rv_characters, 0, R.id.item_title, "Luke Skywalker") 51 | BaristaListInteractions.clickListItem(R.id.rv_characters, 0) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.utils 2 | 3 | import android.content.Context 4 | import android.view.View 5 | import android.widget.EditText 6 | import android.widget.Toast 7 | import androidx.core.widget.addTextChangedListener 8 | import androidx.fragment.app.FragmentActivity 9 | import androidx.lifecycle.ViewModel 10 | import androidx.lifecycle.ViewModelProvider 11 | import com.google.gson.Gson 12 | import com.google.gson.reflect.TypeToken 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlin.math.floor 16 | import kotlin.math.roundToInt 17 | 18 | inline fun FragmentActivity.viewModelProvider( 19 | provider: ViewModelProvider.Factory 20 | ) = ViewModelProvider(this, provider).get(VM::class.java) 21 | 22 | /** 23 | * extension function that make any view visible 24 | */ 25 | fun View.show() { 26 | visibility = View.VISIBLE 27 | } 28 | 29 | /** 30 | * extension function that hide any view (gone) 31 | */ 32 | fun View.hide() { 33 | visibility = View.GONE 34 | } 35 | 36 | 37 | /** 38 | * extension function that takes a url string as http and return it with https instead 39 | */ 40 | fun String.convertUrlToHttps() = if (this.isNotEmpty()) { 41 | substring(0, 4) + 's' + substring(4) 42 | } else { 43 | UNDEFINED 44 | } 45 | 46 | /** 47 | * extension function for the Toast class that takes a string 48 | */ 49 | fun Context.toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() 50 | 51 | 52 | /** 53 | * extension function to convert cm to feet 54 | * 1 foot = 30.48 cm 55 | * 1 inch = 2.54 cm 56 | * 1 foot = 12 inches 57 | */ 58 | @Throws(NumberFormatException::class) 59 | fun String.convertCmToFeet(): String { 60 | val feet = floor(toDouble() / 30.48).toInt() 61 | val inches = (toDouble() / 2.54 - feet * 12).roundToInt() 62 | return String.format("%d' %d''", feet, inches) 63 | } 64 | 65 | /** 66 | * check if a string from the remote API has a value 67 | */ 68 | fun String.hasValue() = this != UNDEFINED 69 | 70 | 71 | /** 72 | * inline function to convert json string to a TypeToken generic type 73 | */ 74 | inline fun Gson.fromJsonToObjectType(json: String): T = 75 | fromJson(json, object : TypeToken() {}.type) 76 | 77 | 78 | /** 79 | * Listen to change on the edit text and return the value in a state flow 80 | */ 81 | fun EditText.getTextChangeStateFlow(): StateFlow { 82 | val query = MutableStateFlow("") 83 | addTextChangedListener { 84 | query.value = it.toString() 85 | } 86 | return query 87 | } -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/viewModels/details/DetailsCharactersViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.viewModels.details 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import com.google.common.truth.Truth.assertThat 5 | import com.hb.stars.data.commun.onError 6 | import com.hb.stars.data.commun.onSuccess 7 | import com.hb.stars.datasource.StarWarsDataSourceImplTest 8 | import com.hb.stars.domain.usecases.GetMoviesUseCase 9 | import com.hb.stars.domain.usecases.GetPlanetUseCase 10 | import com.hb.stars.domain.usecases.GetSpeciesUseCase 11 | import com.hb.stars.helpers.MainCoroutineRule 12 | import com.hb.stars.ui.details.DetailsCharactersViewModel 13 | import com.hb.stars.repository.StarWarsRepositoryImplTest 14 | import kotlinx.coroutines.ExperimentalCoroutinesApi 15 | import org.junit.Before 16 | import org.junit.Rule 17 | import org.junit.Test 18 | import org.junit.runner.RunWith 19 | import org.junit.runners.JUnit4 20 | 21 | @RunWith(JUnit4::class) 22 | class DetailsCharactersViewModelTest { 23 | 24 | @get:Rule 25 | var instantTaskExecutorRule = InstantTaskExecutorRule() 26 | 27 | @ExperimentalCoroutinesApi 28 | @get:Rule 29 | var mainCoroutineRule = MainCoroutineRule() 30 | 31 | private lateinit var viewModel: DetailsCharactersViewModel 32 | 33 | @Before 34 | fun setup() { 35 | val repo = StarWarsRepositoryImplTest(StarWarsDataSourceImplTest()) 36 | viewModel = DetailsCharactersViewModel( 37 | GetMoviesUseCase(repo), 38 | GetPlanetUseCase(repo), 39 | GetSpeciesUseCase(repo) 40 | ) 41 | } 42 | 43 | @Test 44 | fun getSpecie() { 45 | viewModel.getSpecie("specie url") 46 | 47 | viewModel.resultSpecie.value?.onSuccess { result -> 48 | assertThat(result).isNotNull() 49 | assertThat(result.name).matches("Mirialan") 50 | }?.onError { error -> 51 | assertThat(error).isNull() 52 | } 53 | } 54 | 55 | @Test 56 | fun getMovie() { 57 | viewModel.getMovie("movie url") 58 | 59 | viewModel.resultMovie.value?.onSuccess { result -> 60 | assertThat(result).isNotNull() 61 | assertThat(result.title).matches("Attack of the Clones") 62 | assertThat(result.description).matches("There is unrest") 63 | }?.onError { error -> 64 | assertThat(error).isNull() 65 | } 66 | } 67 | 68 | @Test 69 | fun getPlanet() { 70 | viewModel.getPlanet("planet url") 71 | 72 | viewModel.resultPlanet.value?.onSuccess { result -> 73 | assertThat(result).isNotNull() 74 | assertThat(result.population).matches("200000") 75 | }?.onError { error -> 76 | assertThat(error).isNull() 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/item_specie.xml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | 15 | 27 | 28 | 29 | 42 | 43 | 44 | 57 | 58 | 59 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/repository/StarWarsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.repository 2 | 3 | import com.hb.stars.data.commun.StarWarsResult 4 | import com.hb.stars.data.commun.onFlowStarts 5 | import com.hb.stars.data.datasource.remote.StarWarsDataSource 6 | import com.hb.stars.domain.models.CharacterModel 7 | import com.hb.stars.domain.models.MovieModel 8 | import com.hb.stars.domain.models.PlanetModel 9 | import com.hb.stars.domain.models.SpecieModel 10 | import com.hb.stars.domain.repository.StarWarsRepository 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.flow 13 | 14 | class StarWarsRepositoryImpl(private val starWarsDataSource: StarWarsDataSource) : 15 | StarWarsRepository { 16 | override suspend fun searchCharacters(input: String): Flow>> { 17 | return flow { 18 | starWarsDataSource.searchCharacters(input).run { 19 | when (this) { 20 | is StarWarsResult.Success -> { 21 | data?.let { emit(StarWarsResult.Success(data.map { it.mapToDomainModel() })) } 22 | } 23 | is StarWarsResult.Error -> { 24 | emit(StarWarsResult.Error(exception)) 25 | } 26 | } 27 | } 28 | }.onFlowStarts() 29 | } 30 | 31 | override suspend fun getPlanet(planetUrl: String): Flow> = 32 | flow { 33 | starWarsDataSource.getPlanet(planetUrl).run { 34 | when (this) { 35 | is StarWarsResult.Success -> { 36 | data?.let { emit(StarWarsResult.Success(it.mapToDomainModel())) } 37 | } 38 | is StarWarsResult.Error -> { 39 | emit(StarWarsResult.Error(exception)) 40 | } 41 | } 42 | } 43 | }.onFlowStarts() 44 | 45 | override suspend fun getSpecies(specieUrl: List): Flow>> = 46 | flow { 47 | starWarsDataSource.getSpecies(specieUrl).run { 48 | when (this) { 49 | is StarWarsResult.Success -> { 50 | emit(StarWarsResult.Success(data.map { specie -> specie!!.mapToDomainModel() })) 51 | } 52 | is StarWarsResult.Error -> { 53 | emit(StarWarsResult.Error(exception)) 54 | } 55 | } 56 | } 57 | }.onFlowStarts() 58 | 59 | override suspend fun getMovies(movieUrl: List): Flow>> = 60 | flow { 61 | when (val result = starWarsDataSource.getMovies(movieUrl)) { 62 | is StarWarsResult.Success -> { 63 | emit(StarWarsResult.Success(result.data.map { movie -> movie!!.mapToDomainModel() })) 64 | } 65 | is StarWarsResult.Error -> { 66 | emit(StarWarsResult.Error(result.exception)) 67 | } 68 | } 69 | }.onFlowStarts() 70 | } 71 | -------------------------------------------------------------------------------- /app/src/test/java/com/hb/stars/repository/StarWarsRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.repository 2 | 3 | import com.hb.stars.data.commun.StarWarsResult 4 | import com.hb.stars.datasource.StarWarsDataSourceImplTest 5 | import com.hb.stars.domain.models.CharacterModel 6 | import com.hb.stars.domain.models.MovieModel 7 | import com.hb.stars.domain.models.PlanetModel 8 | import com.hb.stars.domain.models.SpecieModel 9 | import com.hb.stars.domain.repository.StarWarsRepository 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.flow 12 | import kotlinx.coroutines.flow.onStart 13 | 14 | class StarWarsRepositoryImplTest(private val starWarsDataSourceImplTest: StarWarsDataSourceImplTest) : 15 | StarWarsRepository { 16 | override suspend fun searchCharacters(input: String): Flow>> { 17 | return flow { 18 | starWarsDataSourceImplTest.searchCharacters(input).run { 19 | when (this) { 20 | is StarWarsResult.Success -> { 21 | data?.let { emit(StarWarsResult.Success(it.map { character -> character.mapToDomainModel() })) } 22 | } 23 | is StarWarsResult.Error -> { 24 | emit(StarWarsResult.Error(exception)) 25 | } 26 | } 27 | } 28 | }.onStart { emit(StarWarsResult.Loading) } 29 | } 30 | 31 | override suspend fun getPlanet(planetUrl: String): Flow> { 32 | return flow { 33 | starWarsDataSourceImplTest.getPlanet(planetUrl).run { 34 | when (this) { 35 | is StarWarsResult.Success -> { 36 | data?.let { emit(StarWarsResult.Success(it.mapToDomainModel())) } 37 | } 38 | is StarWarsResult.Error -> { 39 | emit(StarWarsResult.Error(exception)) 40 | } 41 | } 42 | } 43 | }.onStart { emit(StarWarsResult.Loading) } 44 | } 45 | 46 | override suspend fun getSpecies(specieUrl: String): Flow> { 47 | return flow { 48 | starWarsDataSourceImplTest.getSpecies(specieUrl).run { 49 | when (this) { 50 | is StarWarsResult.Success -> { 51 | data?.let { emit(StarWarsResult.Success(it.mapToDomainModel())) } 52 | } 53 | is StarWarsResult.Error -> { 54 | emit(StarWarsResult.Error(exception)) 55 | } 56 | } 57 | } 58 | }.onStart { emit(StarWarsResult.Loading) } 59 | } 60 | 61 | override suspend fun getMovie(movieUrl: String): Flow> { 62 | return flow { 63 | starWarsDataSourceImplTest.getMovie(movieUrl).run { 64 | when (this) { 65 | is StarWarsResult.Success -> { 66 | data?.let { emit(StarWarsResult.Success(it.mapToDomainModel())) } 67 | } 68 | is StarWarsResult.Error -> { 69 | emit(StarWarsResult.Error(exception)) 70 | } 71 | } 72 | } 73 | }.onStart { emit(StarWarsResult.Loading) } 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/data/datasource/remote/StarWarsDataSourceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.data.datasource.remote 2 | 3 | import com.hb.stars.R 4 | import com.hb.stars.data.commun.DataSourceException 5 | import com.hb.stars.data.commun.RequestErrorHandler 6 | import com.hb.stars.data.commun.StarWarsResult 7 | import com.hb.stars.data.commun.asyncAll 8 | import com.hb.stars.data.response.CharacterResponse 9 | import com.hb.stars.data.response.MovieResponse 10 | import com.hb.stars.data.response.PlanetResponse 11 | import com.hb.stars.data.response.SpecieResponse 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.awaitAll 14 | import kotlinx.coroutines.withContext 15 | 16 | class StarWarsDataSourceImpl(private val starWarsApi: StarWarsServices) : StarWarsDataSource { 17 | override suspend fun searchCharacters(input: String): StarWarsResult?> { 18 | return try { 19 | val result = starWarsApi.searchCharacters(input) 20 | if (result.isSuccessful) { 21 | StarWarsResult.Success(result.body()?.results) 22 | } else { 23 | StarWarsResult.Error(DataSourceException.Server(result.errorBody())) 24 | } 25 | } catch (e: Exception) { 26 | StarWarsResult.Error(RequestErrorHandler.getRequestError(e)) 27 | } 28 | } 29 | 30 | override suspend fun getPlanet(planetUrl: String): StarWarsResult { 31 | return try { 32 | val result = starWarsApi.getPlanet(planetUrl) 33 | if (result.isSuccessful) { 34 | StarWarsResult.Success(result.body()) 35 | } else { 36 | StarWarsResult.Error(DataSourceException.Server(result.errorBody())) 37 | } 38 | } catch (e: Exception) { 39 | StarWarsResult.Error(RequestErrorHandler.getRequestError(e)) 40 | } 41 | } 42 | 43 | override suspend fun getSpecies(specieUrls: List): StarWarsResult> { 44 | return try { 45 | val species = ArrayList() 46 | withContext(Dispatchers.IO) { 47 | asyncAll(specieUrls) { starWarsApi.getSpecie(it) } 48 | .awaitAll() 49 | .forEach { 50 | if (it.isSuccessful) { 51 | species.add(it.body()) 52 | } 53 | } 54 | } 55 | if (species.isNotEmpty()) { 56 | StarWarsResult.Success(species) 57 | } else { 58 | StarWarsResult.Error(DataSourceException.Server(R.string.error_unexpected_message)) 59 | } 60 | } catch (e: Exception) { 61 | StarWarsResult.Error(RequestErrorHandler.getRequestError(e)) 62 | } 63 | } 64 | 65 | override suspend fun getMovies(movieUrls: List): StarWarsResult> { 66 | return try { 67 | val movies = ArrayList() 68 | withContext(Dispatchers.IO) { 69 | asyncAll(movieUrls) { starWarsApi.getMovie(it) } 70 | .awaitAll() 71 | .forEach { 72 | if (it.isSuccessful) { 73 | movies.add(it.body()) 74 | } 75 | } 76 | } 77 | if (movies.isNotEmpty()) { 78 | StarWarsResult.Success(movies) 79 | } else { 80 | StarWarsResult.Error(DataSourceException.Server(R.string.error_unexpected_message)) 81 | } 82 | } catch (e: Exception) { 83 | StarWarsResult.Error(RequestErrorHandler.getRequestError(e)) 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | id 'kotlin-parcelize' 6 | } 7 | apply from: "$project.rootDir/spotless.gradle" 8 | 9 | android { 10 | compileSdkVersion 30 11 | buildToolsVersion "30.0.3" 12 | 13 | defaultConfig { 14 | applicationId "com.hb.stars" 15 | minSdkVersion 23 16 | targetSdkVersion 30 17 | versionCode 1 18 | versionName "1.0" 19 | 20 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 21 | } 22 | 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility JavaVersion.VERSION_1_8 31 | targetCompatibility JavaVersion.VERSION_1_8 32 | } 33 | kotlinOptions { 34 | jvmTarget = '1.8' 35 | } 36 | buildFeatures { 37 | viewBinding = true 38 | } 39 | } 40 | 41 | dependencies { 42 | def coreKtx = "1.3.2" 43 | def appCompact = "1.2.0" 44 | def material = "1.3.0" 45 | def constraintLayout = "2.0.4" 46 | def retrofit = "2.9.0" 47 | def okhttp = "4.9.0" 48 | def gson = "2.8.6" 49 | def dagger = "2.30.1" 50 | def viewmodel = "2.3.0" 51 | def coroutine = "1.4.2" 52 | def stetho = "1.5.1" 53 | def testCoreRunner = "1.2.0" 54 | def espresso = "3.3.0" 55 | def googleTruth = "1.0" 56 | def barista = "3.7.0" 57 | def androidxJunit = "1.1.2" 58 | def junit = "4.13.2" 59 | def androidxArchTest = "2.1.0" 60 | 61 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 62 | implementation "androidx.core:core-ktx:$coreKtx" 63 | implementation "androidx.appcompat:appcompat:$appCompact" 64 | implementation "com.google.android.material:material:$material" 65 | implementation "androidx.constraintlayout:constraintlayout:$constraintLayout" 66 | 67 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$viewmodel" 68 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:$viewmodel" 69 | 70 | //Retrofit2 71 | implementation "com.squareup.retrofit2:retrofit:$retrofit" 72 | implementation "com.squareup.retrofit2:converter-gson:$retrofit" 73 | 74 | //Okhttp3 75 | implementation "com.squareup.okhttp3:okhttp:$okhttp" 76 | 77 | //Gson 78 | implementation "com.google.code.gson:gson:$gson" 79 | 80 | //Dagger 81 | implementation "com.google.dagger:dagger:$dagger" 82 | kapt "com.google.dagger:dagger-compiler:$dagger" 83 | 84 | //Coroutines 85 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine" 86 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine" 87 | 88 | //Stetho 89 | implementation "com.facebook.stetho:stetho:$stetho" 90 | implementation "com.facebook.stetho:stetho-okhttp3:$stetho" 91 | 92 | debugImplementation "com.squareup.leakcanary:leakcanary-android:2.4" 93 | 94 | //TEST SUITE 95 | androidTestImplementation "androidx.test:runner:$testCoreRunner" 96 | androidTestImplementation "androidx.test.espresso:espresso-core:$espresso" 97 | androidTestImplementation "androidx.test.ext:junit:$androidxJunit" 98 | androidTestImplementation("com.schibsted.spain:barista:$barista") { 99 | exclude group: "org.jetbrains.kotlin" 100 | } 101 | 102 | testImplementation "androidx.arch.core:core-testing:$androidxArchTest" 103 | testImplementation "junit:junit:$junit" 104 | testImplementation "com.google.truth:truth:$googleTruth" 105 | testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutine" 106 | } -------------------------------------------------------------------------------- /app/src/test/resources/movie.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Attack of the Clones", 3 | "episode_id": 2, 4 | "opening_crawl": "There is unrest in the Galactic\r\nSenate. Several thousand solar\r\nsystems have declared their\r\nintentions to leave the Republic.\r\n\r\nThis separatist movement,\r\nunder the leadership of the\r\nmysterious Count Dooku, has\r\nmade it difficult for the limited\r\nnumber of Jedi Knights to maintain \r\npeace and order in the galaxy.\r\n\r\nSenator Amidala, the former\r\nQueen of Naboo, is returning\r\nto the Galactic Senate to vote\r\non the critical issue of creating\r\nan ARMY OF THE REPUBLIC\r\nto assist the overwhelmed\r\nJedi....", 5 | "director": "George Lucas", 6 | "producer": "Rick McCallum", 7 | "release_date": "2002-05-16", 8 | "characters": [ 9 | "http://swapi.dev/api/people/2/", 10 | "http://swapi.dev/api/people/3/", 11 | "http://swapi.dev/api/people/6/", 12 | "http://swapi.dev/api/people/7/", 13 | "http://swapi.dev/api/people/10/", 14 | "http://swapi.dev/api/people/11/", 15 | "http://swapi.dev/api/people/20/", 16 | "http://swapi.dev/api/people/21/", 17 | "http://swapi.dev/api/people/22/", 18 | "http://swapi.dev/api/people/33/", 19 | "http://swapi.dev/api/people/35/", 20 | "http://swapi.dev/api/people/36/", 21 | "http://swapi.dev/api/people/40/", 22 | "http://swapi.dev/api/people/43/", 23 | "http://swapi.dev/api/people/46/", 24 | "http://swapi.dev/api/people/51/", 25 | "http://swapi.dev/api/people/52/", 26 | "http://swapi.dev/api/people/53/", 27 | "http://swapi.dev/api/people/58/", 28 | "http://swapi.dev/api/people/59/", 29 | "http://swapi.dev/api/people/60/", 30 | "http://swapi.dev/api/people/61/", 31 | "http://swapi.dev/api/people/62/", 32 | "http://swapi.dev/api/people/63/", 33 | "http://swapi.dev/api/people/64/", 34 | "http://swapi.dev/api/people/65/", 35 | "http://swapi.dev/api/people/66/", 36 | "http://swapi.dev/api/people/67/", 37 | "http://swapi.dev/api/people/68/", 38 | "http://swapi.dev/api/people/69/", 39 | "http://swapi.dev/api/people/70/", 40 | "http://swapi.dev/api/people/71/", 41 | "http://swapi.dev/api/people/72/", 42 | "http://swapi.dev/api/people/73/", 43 | "http://swapi.dev/api/people/74/", 44 | "http://swapi.dev/api/people/75/", 45 | "http://swapi.dev/api/people/76/", 46 | "http://swapi.dev/api/people/77/", 47 | "http://swapi.dev/api/people/78/", 48 | "http://swapi.dev/api/people/82/" 49 | ], 50 | "planets": [ 51 | "http://swapi.dev/api/planets/1/", 52 | "http://swapi.dev/api/planets/8/", 53 | "http://swapi.dev/api/planets/9/", 54 | "http://swapi.dev/api/planets/10/", 55 | "http://swapi.dev/api/planets/11/" 56 | ], 57 | "starships": [ 58 | "http://swapi.dev/api/starships/21/", 59 | "http://swapi.dev/api/starships/32/", 60 | "http://swapi.dev/api/starships/39/", 61 | "http://swapi.dev/api/starships/43/", 62 | "http://swapi.dev/api/starships/47/", 63 | "http://swapi.dev/api/starships/48/", 64 | "http://swapi.dev/api/starships/49/", 65 | "http://swapi.dev/api/starships/52/", 66 | "http://swapi.dev/api/starships/58/" 67 | ], 68 | "vehicles": [ 69 | "http://swapi.dev/api/vehicles/4/", 70 | "http://swapi.dev/api/vehicles/44/", 71 | "http://swapi.dev/api/vehicles/45/", 72 | "http://swapi.dev/api/vehicles/46/", 73 | "http://swapi.dev/api/vehicles/50/", 74 | "http://swapi.dev/api/vehicles/51/", 75 | "http://swapi.dev/api/vehicles/53/", 76 | "http://swapi.dev/api/vehicles/54/", 77 | "http://swapi.dev/api/vehicles/55/", 78 | "http://swapi.dev/api/vehicles/56/", 79 | "http://swapi.dev/api/vehicles/57/" 80 | ], 81 | "species": [ 82 | "http://swapi.dev/api/species/1/", 83 | "http://swapi.dev/api/species/2/", 84 | "http://swapi.dev/api/species/6/", 85 | "http://swapi.dev/api/species/12/", 86 | "http://swapi.dev/api/species/13/", 87 | "http://swapi.dev/api/species/15/", 88 | "http://swapi.dev/api/species/28/", 89 | "http://swapi.dev/api/species/29/", 90 | "http://swapi.dev/api/species/30/", 91 | "http://swapi.dev/api/species/31/", 92 | "http://swapi.dev/api/species/32/", 93 | "http://swapi.dev/api/species/33/", 94 | "http://swapi.dev/api/species/34/", 95 | "http://swapi.dev/api/species/35/" 96 | ], 97 | "created": "2014-12-20T10:57:57.886000Z", 98 | "edited": "2014-12-20T20:18:48.516000Z", 99 | "url": "http://swapi.dev/api/films/5/" 100 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About StarWars App 2 | Hello there , 3 | In this project I tried showcasing how to build an Android Application with clean architecture and MVVM using some of the jetpack libraries with Kotlin Coroutines & Dagger2. This App is using the [Star Wars API](https://swapi.dev/) as a remote data source. 4 | 5 | This side Project was initially a case study given to me as a challenge for an android role. 6 | 7 | I wrote an [article](https://medium.com/@hamdiboumaiza/navigating-mvvm-with-dagger-2-coroutines-and-aac-57a37ac25f3a) a while back in which i talked about some the choices i take when developing an android application. 8 | 9 | This app is based on the [Guide to app architecture](https://developer.android.com/jetpack/docs/guide) article, [Kotlin 1.4](https://kotlinlang.org/docs/reference/whatsnew13.html), and [coroutine](https://kotlinlang.org/docs/reference/coroutines/basics.html). I also used some android architecture components like [LiveData](https://developer.android.com/jetpack/arch/livedata), [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel). 10 | 11 | # Screenshots 12 |

13 | 14 | 15 | 16 |

17 | 18 | # Project Architecture 19 | ### Communication between layers 20 | 1. UI calls method from ViewModel. 21 | 2. ViewModel executes Use case. 22 | 3. Use case executes one or multiple Repositorie function. 23 | 4. The Repository returns data from one or multiple Data Sources. the repository is the single source of truth 24 | 5. Information flows back to the UI where we display the data fetched from data sources. 25 | 26 | I made a diagram to show the flow of the data between the three layers(data, domain , presentation) 27 | ![data flow diagram](screenshots/dataFlowDiagram.png ) 28 | # Project Structure 29 | * Data 30 | * This is my data layer and consisted of the Room Database associated classes, the Network 31 | related classes including the CoinsService interface, and the Repository class as well as 32 | the local and remote data sources 33 | * Domain 34 | * This is the domain layer and consists of the domain model as well as the domain mapper 35 | * UI 36 | * This is the presentation layer. I have set up packages by feature here. This consists of the view related code. 37 | * DI 38 | * This is where Dagger related code lives ,connected to the different layers of the application 39 | * Utils 40 | * This is where most extension functions and constants and some other utils functions exist. 41 | 42 | Libraries Used 43 | --------------- 44 | * [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) - store and manage UI-related data in a lifecycle conscious way 45 | * [StateFlow](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) - enable flows to optimally emit state updates and emit values to multiple consumers.. 46 | * [ViewBinding](https://developer.android.com/topic/libraries/view-binding) - write code that interacts with views more easily 47 | * [Material](https://material.io/develop/android/docs/getting-started/) - Material Components. 48 | * [Coroutine](https://github.com/Kotlin/kotlinx.coroutines#user-content-android) - performs background tasks 49 | * [Flows](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/) - for asynchronous data streams 50 | * [Retrofit2](https://square.github.io/retrofit/)- networking 51 | * [Gson](https://github.com/google/gson) - JSON Parser 52 | * [Dagger2](https://dagger.dev/users-guide) - dependency injector 53 | * [Stetho](http://facebook.github.io/stetho/) - debug bridge 54 | * [Espresso](https://developer.android.com/training/testing/espresso/) // UI test 55 | * [Barsita](https://github.com/AdevintaSpain/Barista) -UI tests Built on top of Espresso 56 | * [Junit](https://junit.org/junit4/) // unit tests 57 | * [Truth](https://github.com/google/truth) // Makes your test assertions and failure messages more readable 58 | 59 | 60 | # To be added 61 | * more testing 62 | * animations 63 | 64 |
65 | 66 | ## License 🔖 67 | ``` 68 | Apache 2.0 License 69 | 70 | 71 | Copyright 2021 Hamdi Boumaiza 72 | 73 | Licensed under the Apache License, Version 2.0 (the "License"); 74 | you may not use this file except in compliance with the License. 75 | You may obtain a copy of the License at 76 | 77 | http://www.apache.org/licenses/LICENSE-2.0 78 | 79 | Unless required by applicable law or agreed to in writing, software 80 | distributed under the License is distributed on an "AS IS" BASIS, 81 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 82 | See the License for the specific language governing permissions and 83 | limitations under the License. 84 | 85 | ``` 86 | 87 | -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/ui/details/DetailsCharactersActivity.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.ui.details 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.lifecycle.lifecycleScope 7 | import androidx.recyclerview.widget.LinearLayoutManager 8 | import com.hb.stars.R 9 | import com.hb.stars.StarWarsApplication 10 | import com.hb.stars.data.commun.DataSourceException 11 | import com.hb.stars.data.commun.onError 12 | import com.hb.stars.data.commun.onLoading 13 | import com.hb.stars.data.commun.onSuccess 14 | import com.hb.stars.databinding.ActivityDetailsCharactersBinding 15 | import com.hb.stars.domain.models.CharacterModel 16 | import com.hb.stars.utils.* 17 | import kotlinx.coroutines.flow.collect 18 | import okhttp3.ResponseBody 19 | import javax.inject.Inject 20 | 21 | class DetailsCharactersActivity : AppCompatActivity() { 22 | 23 | @Inject 24 | lateinit var viewModelFactory: ViewModelProvider.Factory 25 | private val viewModel by lazy { viewModelProvider(viewModelFactory) as DetailsCharactersViewModel } 26 | 27 | private lateinit var binding: ActivityDetailsCharactersBinding 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | super.onCreate(savedInstanceState) 31 | binding = ActivityDetailsCharactersBinding.inflate(layoutInflater) 32 | setContentView(binding.root) 33 | StarWarsApplication.appComponent.inject(this) 34 | initObservers() 35 | initViews() 36 | } 37 | 38 | private fun initViews() { 39 | getExtraCharacter()?.apply { 40 | with(binding) { 41 | tvDetailsNameValue.text = name 42 | tvDetailsBirthYearValue.text = birthYear 43 | if (height.hasValue()) { 44 | tvDetailsHeightValue.text = 45 | getString(R.string.height_in_cm_and_feet, height, height.convertCmToFeet()) 46 | } else { 47 | tvDetailsHeightValue.text = UNDEFINED 48 | } 49 | } 50 | getPlanet(getPlanetUrl()) 51 | getMovies(this) 52 | getSpecies(this) 53 | } 54 | } 55 | 56 | private fun getPlanet(planet: String) { 57 | if (planet.hasValue()) viewModel.getPlanet(planet) 58 | } 59 | 60 | private fun getSpecies(character: CharacterModel) { 61 | if (character.species.isNotEmpty()) { 62 | viewModel.getSpecies(character.getSpeciesUrl()) 63 | } 64 | } 65 | 66 | private fun getMovies(character: CharacterModel) { 67 | if (character.films.isNotEmpty()) { 68 | viewModel.getMovies(character.getMoviesUrl()) 69 | } 70 | } 71 | 72 | private fun getExtraCharacter() = 73 | intent?.extras?.getParcelable(CHARACTER_EXTRA) as CharacterModel? 74 | 75 | 76 | private fun initObservers() { 77 | lifecycleScope.launchWhenStarted { 78 | viewModel.resultMovie.collect { 79 | it.onSuccess { movies -> 80 | binding.progressCircular.hide() 81 | binding.cardMovies.show() 82 | with(binding.rvMovies) { 83 | layoutManager = LinearLayoutManager(this@DetailsCharactersActivity) 84 | adapter = MoviesAdapter(movies) 85 | } 86 | 87 | }.onError { error -> 88 | showError(error) 89 | binding.progressCircular.show() 90 | }.onLoading { 91 | binding.progressCircular.show() 92 | } 93 | } 94 | } 95 | 96 | lifecycleScope.launchWhenStarted { 97 | viewModel.resultPlanet.collect { 98 | it.onSuccess { planet -> 99 | binding.cardPopulation.show() 100 | binding.tvDetailsPopulation.text = 101 | getString( 102 | R.string.population_value, 103 | getExtraCharacter()?.name, 104 | planet.population 105 | ) 106 | binding.progressCircular.hide() 107 | }.onError { error -> 108 | showError(error) 109 | binding.progressCircular.show() 110 | }.onLoading { 111 | binding.progressCircular.show() 112 | } 113 | } 114 | } 115 | 116 | 117 | lifecycleScope.launchWhenStarted { 118 | viewModel.resultSpecie.collect { 119 | it.onSuccess { species -> 120 | binding.progressCircular.hide() 121 | binding.cardSpecies.show() 122 | with(binding.rvSpecies) { 123 | layoutManager = LinearLayoutManager(this@DetailsCharactersActivity) 124 | adapter = SpeciesAdapter(species) 125 | } 126 | 127 | }.onError { error -> 128 | showError(error) 129 | binding.progressCircular.show() 130 | }.onLoading { 131 | binding.progressCircular.show() 132 | } 133 | } 134 | } 135 | } 136 | 137 | private fun showError(error: DataSourceException) { 138 | when (error.messageResource) { 139 | is Int -> toast(getString(error.messageResource)) 140 | is ResponseBody? -> toast(error.messageResource!!.string()) 141 | } 142 | } 143 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hb/stars/ui/search/SearchCharactersActivity.kt: -------------------------------------------------------------------------------- 1 | package com.hb.stars.ui.search 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.view.MotionEvent 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.lifecycle.lifecycleScope 9 | import androidx.recyclerview.widget.LinearLayoutManager 10 | import com.hb.stars.R 11 | import com.hb.stars.StarWarsApplication 12 | import com.hb.stars.data.commun.StarWarsResult 13 | import com.hb.stars.data.commun.onError 14 | import com.hb.stars.data.commun.onLoading 15 | import com.hb.stars.data.commun.onSuccess 16 | import com.hb.stars.databinding.ActivitySearchCharactersBinding 17 | import com.hb.stars.di.viewmodel.DaggerViewModelFactory 18 | import com.hb.stars.domain.models.CharacterModel 19 | import com.hb.stars.ui.details.DetailsCharactersActivity 20 | import com.hb.stars.utils.* 21 | import kotlinx.coroutines.Dispatchers 22 | import kotlinx.coroutines.ExperimentalCoroutinesApi 23 | import kotlinx.coroutines.FlowPreview 24 | import kotlinx.coroutines.flow.* 25 | import kotlinx.coroutines.launch 26 | import okhttp3.ResponseBody 27 | import javax.inject.Inject 28 | 29 | class SearchCharactersActivity : AppCompatActivity() { 30 | 31 | @Inject 32 | lateinit var viewModelFactory: DaggerViewModelFactory 33 | private val viewModel by lazy { viewModelProvider(viewModelFactory) as SearchCharactersViewModel } 34 | 35 | private lateinit var binding: ActivitySearchCharactersBinding 36 | private val linearLayoutManager by lazy { LinearLayoutManager(this) } 37 | private lateinit var charactersAdapter: CharactersAdapter 38 | 39 | 40 | @ExperimentalCoroutinesApi 41 | @FlowPreview 42 | override fun onCreate(savedInstanceState: Bundle?) { 43 | super.onCreate(savedInstanceState) 44 | binding = ActivitySearchCharactersBinding.inflate(layoutInflater) 45 | setContentView(binding.root) 46 | StarWarsApplication.appComponent.inject(this) 47 | checkInternetAvailability() 48 | setEditTextListener() 49 | } 50 | 51 | private fun checkInternetAvailability() { 52 | ConnectionLiveData(this).observe(this) { 53 | if (!it) toast(getString(R.string.error_network)) 54 | } 55 | } 56 | 57 | 58 | @FlowPreview 59 | @ExperimentalCoroutinesApi 60 | @SuppressLint("ClickableViewAccessibility") 61 | private fun setEditTextListener() { 62 | lifecycleScope.launch { 63 | binding.etSearch.getTextChangeStateFlow() 64 | .debounce(300) 65 | .filter { query -> 66 | if (query.isEmpty()) { 67 | runOnUiThread { setError(null) } 68 | return@filter false 69 | } else { 70 | return@filter true 71 | } 72 | } 73 | .distinctUntilChanged() 74 | .flatMapLatest { query -> 75 | viewModel.searchCharacters(query) 76 | } 77 | .flowOn(Dispatchers.Default) 78 | .collect { result -> 79 | processResult(result) 80 | } 81 | } 82 | 83 | binding.etSearch.setOnTouchListener { view, event -> 84 | when (event.action) { 85 | MotionEvent.ACTION_UP -> { 86 | if (event.rawX >= binding.etSearch.right - binding.etSearch.compoundDrawables[2].bounds.width()) { 87 | binding.etSearch.text = null 88 | } 89 | } 90 | else -> view.performClick() 91 | } 92 | false 93 | } 94 | } 95 | 96 | private fun processResult(result: StarWarsResult>) { 97 | result.onSuccess { list -> 98 | setListAdapter(list) 99 | }.onError { error -> 100 | when (error.messageResource) { 101 | is Int -> setError(getString(error.messageResource)) 102 | is ResponseBody? -> setError(error.messageResource?.string()) 103 | } 104 | }.onLoading { 105 | binding.groupError.hide() 106 | binding.progressCircular.show() 107 | } 108 | } 109 | 110 | private fun setListAdapter(list: List) { 111 | binding.progressCircular.hide() 112 | if (list.isEmpty()) { 113 | binding.groupError.show() 114 | binding.rvCharacters.hide() 115 | } else { 116 | binding.rvCharacters.show() 117 | binding.groupError.hide() 118 | binding.rvCharacters.show() 119 | if (!::charactersAdapter.isInitialized) { 120 | with(binding.rvCharacters) { 121 | layoutManager = linearLayoutManager 122 | charactersAdapter = CharactersAdapter { setOnCharacterClicked(it) } 123 | adapter = charactersAdapter 124 | charactersAdapter.submitList(list) 125 | } 126 | } else { 127 | charactersAdapter.submitList(list) 128 | } 129 | } 130 | } 131 | 132 | private fun setOnCharacterClicked(characterModel: CharacterModel) { 133 | Intent(this, DetailsCharactersActivity::class.java).apply { 134 | putExtra(CHARACTER_EXTRA, characterModel) 135 | startActivity(this) 136 | } 137 | } 138 | 139 | private fun setError(error: String?) { 140 | binding.rvCharacters.hide() 141 | binding.progressCircular.hide() 142 | binding.groupError.show() 143 | binding.tvError.text = error 144 | } 145 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_search_characters.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 22 | 23 | 29 | 30 | 40 | 41 | 60 | 61 | 74 | 75 | 88 | 89 | 90 | 101 | 102 | 111 | 112 | 113 | 126 | 127 | 140 | 141 | 152 | 153 | 159 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_details_characters.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 13 | 14 | 20 | 21 | 27 | 28 | 36 | 37 | 49 | 50 | 61 | 62 | 63 | 74 | 75 | 85 | 86 | 97 | 98 | 110 | 111 | 122 | 123 | 124 | 125 | 137 | 138 | 146 | 147 | 148 | 149 | 161 | 162 | 165 | 166 | 176 | 177 | 187 | 188 | 189 | 190 | 191 | 203 | 204 | 207 | 208 | 209 | 219 | 220 | 230 | 231 | 232 | 233 | 234 | --------------------------------------------------------------------------------