diff --git a/.gitignore b/.gitignore index 9298b749f..aa724b770 100644 --- a/.gitignore +++ b/.gitignore @@ -1,200 +1,15 @@ -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore -**.DS_Store -.build -DerivedData -/.previous-build -xcuserdata +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml .DS_Store -*~ -\#* -.\#* -.*.sw[nop] -*.xcscmblueprint -/default.profraw -Utilities/Docker/*.tar.gz -.swiftpm -Package.resolved /build -*.pyc - -## User settings -xcuserdata/ - -## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) -*.moved-aside -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 - -## Obj-C/Swift specific -*.hmap - -## App packaging -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace - -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - -.build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# Accio dependency management -Dependencies/ -.accio/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output - -# Code Injection -# -# After new code Injection tools there's a generated folder /iOSInjectionProject -# https://github.com/johnno1962/injectionforxcode - -iOSInjectionProject/ - -**/ApolloCLI - -####################################### -############## ANDROID ################ -####################################### - -# Built application files -*.apk -*.aar -*.ap_ -*.aab - -# Files for the ART/Dalvik VM -*.dex - -# Java class files -*.class - -# Generated files -bin/ -gen/ -out/ -# Uncomment the following line in case you need and you don't have the release build type files in your app -# release/ - -# Gradle files -.gradle/ -build/ - -# Local configuration file (sdk path, etc) -local.properties - -# Proguard folder generated by Eclipse -proguard/ - -# Log Files -*.log - -# Android Studio Navigation editor temp files -.navigation/ - -# Android Studio captures folder -captures/ - -# IntelliJ -*.iml -.idea/workspace.xml -.idea/tasks.xml -.idea/gradle.xml -.idea/assetWizardSettings.xml -.idea/dictionaries -.idea/libraries -# Android Studio 3 in .gitignore file. -.idea/caches -.idea/modules.xml -# Comment next line if keeping position of elements in Navigation Editor is relevant for you -.idea/navEditor.xml - -# Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore - -# External native build folder generated in Android Studio 2.2 and later +/captures .externalNativeBuild -.cxx/ - -# Google Services (e.g. APIs or Firebase) -# google-services.json - -# Freeline -freeline.py -freeline/ -freeline_project_description.json - -# fastlane -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots -fastlane/test_output -fastlane/readme.md - -# Version control -vcs.xml - -# lint -lint/intermediates/ -lint/generated/ -lint/outputs/ -lint/tmp/ -# lint/reports/ - -# Android Profiling -*.hprof +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..b589d56e9 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 000000000..0c0c33838 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 000000000..0897082f7 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..44ca2d9b0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,41 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 000000000..fdf8d994a --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 000000000..f8051a6f9 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..8978d23db --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..288b36b1e --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..0710ef698 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,105 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.jetbrainsKotlinAndroid) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.hilt.kapt) + alias(libs.plugins.kotlin.parcelize) + kotlin("kapt") +} + +android { + namespace = "com.example.hurb_challenge" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.example.hurb_challenge" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = libs.versions.versionCode.get().toInt() + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + android.buildFeatures.buildConfig = true + + buildTypes { + debug { + buildConfigField("String", "BASE_URL", "\"https://swapi.dev/api/\"") + buildConfigField("String", "BASE_IMAGE_URL", "\"https://starwars-visualguide.com/assets/img/\"",) + } + + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + buildConfigField("String", "BASE_URL", "\"https://swapi.dev/api/\"") + buildConfigField("String", "BASE_IMAGE_URL", "\"https://starwars-visualguide.com/assets/img/\"",) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.retrofit) + implementation(libs.okhttp) + implementation(libs.logging.interceptor) + implementation(libs.converter.gson) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.paging) + implementation(libs.paging.compose) + implementation(libs.splashscreen) + kapt(libs.hilt.android.compiler) + + implementation(libs.coil.compose) + implementation(libs.material.icons.extended) + + implementation(libs.navigation.compose) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.hilt.android.testing) + testImplementation(libs.kotlinx.coroutines.test) + kaptTest(libs.hilt.compiler) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/hurb_challenge/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/hurb_challenge/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..bdc781308 --- /dev/null +++ b/app/src/androidTest/java/com/example/hurb_challenge/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.hurb_challenge + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.hurb_challenge", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..6d996ae96 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/HurbChallengeApp.kt b/app/src/main/java/com/example/hurb_challenge/app/HurbChallengeApp.kt new file mode 100644 index 000000000..8eff093f1 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/HurbChallengeApp.kt @@ -0,0 +1,7 @@ +package com.example.hurb_challenge.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class HurbChallengeApp : Application() \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/core/CoreModule.kt b/app/src/main/java/com/example/hurb_challenge/app/core/CoreModule.kt new file mode 100644 index 000000000..305f1ec95 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/core/CoreModule.kt @@ -0,0 +1,18 @@ +package com.example.hurb_challenge.app.core + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object CoreModule { + + @Provides + @Singleton + fun provideCoroutineDispatcherIO(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/core/ExtensionFunctions.kt b/app/src/main/java/com/example/hurb_challenge/app/core/ExtensionFunctions.kt new file mode 100644 index 000000000..ee1db1b27 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/core/ExtensionFunctions.kt @@ -0,0 +1,33 @@ +package com.example.hurb_challenge.app.core + +import android.os.Bundle +import androidx.navigation.NavType +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +fun String.extractNumbers() = filter { it.isDigit() } + +fun String.encodeUrl(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.toString()) + +fun String.decodeUrl(): String = URLDecoder.decode(this, StandardCharsets.UTF_8.toString()) + +fun List.encodeUrl() = map { url -> url.encodeUrl() } + +inline fun serializableType( + isNullableAllowed: Boolean = false, + json: Json = Json, +) = object : NavType(isNullableAllowed = isNullableAllowed) { + override fun get(bundle: Bundle, key: String) = + bundle.getString(key)?.let(json::decodeFromString) + + override fun parseValue(value: String): T = json.decodeFromString(value) + + override fun serializeAsValue(value: T): String = json.encodeToString(value) + + override fun put(bundle: Bundle, key: String, value: T) { + bundle.putString(key, json.encodeToString(value)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/ApiService.kt b/app/src/main/java/com/example/hurb_challenge/app/data/ApiService.kt new file mode 100644 index 000000000..17bc351c8 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/ApiService.kt @@ -0,0 +1,28 @@ +package com.example.hurb_challenge.app.data + +import com.example.hurb_challenge.app.data.dto.ApiResponse +import com.example.hurb_challenge.app.data.dto.CharacterDto +import com.example.hurb_challenge.app.data.dto.FilmDto +import com.example.hurb_challenge.app.data.dto.StarshipDto +import com.example.hurb_challenge.app.data.dto.VehicleDto +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.Url + +interface ApiService { + + @GET("people/") + suspend fun getCharacters(@Query("page") page: Int): ApiResponse + + @GET("films/") + suspend fun getFilms(@Query("page") page: Int): ApiResponse + + @GET + suspend fun getCharacter(@Url url: String): CharacterDto + + @GET + suspend fun getVehicle(@Url url: String): VehicleDto + + @GET + suspend fun getStarship(@Url url: String): StarshipDto +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/Result.kt b/app/src/main/java/com/example/hurb_challenge/app/data/Result.kt new file mode 100644 index 000000000..ebc21673e --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/Result.kt @@ -0,0 +1,37 @@ +package com.example.hurb_challenge.app.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import retrofit2.HttpException +import java.net.UnknownHostException + +sealed class Result + +sealed class Failure : Result() + +data class HttpFailure( + val errorCode: Int +) : Failure() + +data object ConnectionFailure : Failure() + +data object UnknownFailure : Failure() + +data object Fetching : Result() + +data class Success(val value: T) : Result() + +fun Flow.asResult(): Flow> { + return this + .map> { Success(it) } + .onStart { emit(Fetching) } + .catch { failure -> + when (failure) { + is UnknownHostException -> emit(ConnectionFailure) + is HttpException -> emit(HttpFailure(failure.code())) + else -> emit(UnknownFailure) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/dto/ApiResponse.kt b/app/src/main/java/com/example/hurb_challenge/app/data/dto/ApiResponse.kt new file mode 100644 index 000000000..b21f9fea2 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/dto/ApiResponse.kt @@ -0,0 +1,8 @@ +package com.example.hurb_challenge.app.data.dto + +data class ApiResponse( + val count: Int, + val next: String?, + val previous: String?, + val results: List +) diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/dto/CharacterDto.kt b/app/src/main/java/com/example/hurb_challenge/app/data/dto/CharacterDto.kt new file mode 100644 index 000000000..3f5adbb2b --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/dto/CharacterDto.kt @@ -0,0 +1,13 @@ +package com.example.hurb_challenge.app.data.dto + +import com.google.gson.annotations.SerializedName + +data class CharacterDto( + val name: String, + val height: String, + val gender: String, + val url: String, + @SerializedName("birth_year") val birthYear: String, + @SerializedName("vehicles") val vehiclesUrl: List, + @SerializedName("starships") val starshipsUrl: List +) diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/dto/FilmDto.kt b/app/src/main/java/com/example/hurb_challenge/app/data/dto/FilmDto.kt new file mode 100644 index 000000000..27d9f79cf --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/dto/FilmDto.kt @@ -0,0 +1,14 @@ +package com.example.hurb_challenge.app.data.dto + +import com.google.gson.annotations.SerializedName + +data class FilmDto( + val title: String, + @SerializedName("episode_id") val episode: Int, + @SerializedName("opening_crawl") val openingCrawl: String, + val director: String, + val url: String, + @SerializedName("release_date") val releaseDate: String, + @SerializedName("characters") val charactersUrl: List + +) diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/dto/StarshipDto.kt b/app/src/main/java/com/example/hurb_challenge/app/data/dto/StarshipDto.kt new file mode 100644 index 000000000..4a29856a5 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/dto/StarshipDto.kt @@ -0,0 +1,7 @@ +package com.example.hurb_challenge.app.data.dto + +data class StarshipDto( + val name: String, + val model: String, + val url: String +) diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/dto/VehicleDto.kt b/app/src/main/java/com/example/hurb_challenge/app/data/dto/VehicleDto.kt new file mode 100644 index 000000000..665e34269 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/dto/VehicleDto.kt @@ -0,0 +1,7 @@ +package com.example.hurb_challenge.app.data.dto + +data class VehicleDto( + val name: String, + val model: String, + val url: String +) diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/module/NetworkModule.kt b/app/src/main/java/com/example/hurb_challenge/app/data/module/NetworkModule.kt new file mode 100644 index 000000000..ca3ef7786 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/module/NetworkModule.kt @@ -0,0 +1,39 @@ +package com.example.hurb_challenge.app.data.module + +import com.example.hurb_challenge.BuildConfig +import com.example.hurb_challenge.app.data.ApiService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +@InstallIn(SingletonComponent::class) +@Module +object NetworkModule { + + @Provides + fun provideOkHttp() = OkHttpClient.Builder() + .addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(40, TimeUnit.SECONDS) + .build() + + @Provides + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .client(okHttpClient) + .build() + + + @Provides + fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java) +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/module/RepositoryModule.kt b/app/src/main/java/com/example/hurb_challenge/app/data/module/RepositoryModule.kt new file mode 100644 index 000000000..71a5c70d9 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/module/RepositoryModule.kt @@ -0,0 +1,48 @@ +package com.example.hurb_challenge.app.data.module + +import com.example.hurb_challenge.app.data.ApiService +import com.example.hurb_challenge.app.data.repository.CharacterDetailsRepositoryImpl +import com.example.hurb_challenge.app.data.repository.CharactersRepositoryImpl +import com.example.hurb_challenge.app.data.repository.FilmDetailsRepositoryImpl +import com.example.hurb_challenge.app.data.repository.FilmsRepositoryImpl +import com.example.hurb_challenge.app.domain.repository.CharacterDetailsRepository +import com.example.hurb_challenge.app.domain.repository.CharactersRepository +import com.example.hurb_challenge.app.domain.repository.FilmDetailsRepository +import com.example.hurb_challenge.app.domain.repository.FilmsRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object RepositoryModule { + @Provides + @Singleton + fun provideCharactersRepository( + apiService: ApiService + ): CharactersRepository = + CharactersRepositoryImpl(apiService) + + @Provides + @Singleton + fun provideFilmsRepository( + apiService: ApiService + ): FilmsRepository = + FilmsRepositoryImpl(apiService) + + @Provides + @Singleton + fun provideCharacterDetailsRepository( + apiService: ApiService + ): CharacterDetailsRepository = + CharacterDetailsRepositoryImpl(apiService) + + @Provides + @Singleton + fun provideFilmDetailsRepository( + apiService: ApiService + ): FilmDetailsRepository = + FilmDetailsRepositoryImpl(apiService) +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/pagingsource/CharactersPagingSource.kt b/app/src/main/java/com/example/hurb_challenge/app/data/pagingsource/CharactersPagingSource.kt new file mode 100644 index 000000000..8f72eefd6 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/pagingsource/CharactersPagingSource.kt @@ -0,0 +1,40 @@ +package com.example.hurb_challenge.app.data.pagingsource + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.example.hurb_challenge.app.core.extractNumbers +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.repository.CharactersRepository +import com.example.hurb_challenge.app.domain.toDomain + +class CharactersPagingSource( + private val repository: CharactersRepository +) : PagingSource() { + + override fun getRefreshKey(state: PagingState) = + state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + + override suspend fun load(params: LoadParams) = try { + val currentPage = params.key ?: 1 + var nextPageNumber: Int? = null + var data: List = emptyList() + repository.getCharactersByPage(page = currentPage).collect { response -> + nextPageNumber = if (response.next == null) null else currentPage + 1 + data = response.results.map { dto -> + val id = dto.url.extractNumbers() + dto.toDomain(id) + } + } + LoadResult.Page( + data = data, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = nextPageNumber + ) + + } catch (e: Exception) { + LoadResult.Error(e) + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/pagingsource/FilmsPagingSource.kt b/app/src/main/java/com/example/hurb_challenge/app/data/pagingsource/FilmsPagingSource.kt new file mode 100644 index 000000000..b56699e3f --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/pagingsource/FilmsPagingSource.kt @@ -0,0 +1,40 @@ +package com.example.hurb_challenge.app.data.pagingsource + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.example.hurb_challenge.app.core.extractNumbers +import com.example.hurb_challenge.app.domain.model.Film +import com.example.hurb_challenge.app.domain.repository.FilmsRepository +import com.example.hurb_challenge.app.domain.toDomain + +class FilmsPagingSource( + private val repository: FilmsRepository +) : PagingSource() { + + override fun getRefreshKey(state: PagingState) = + state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + + override suspend fun load(params: LoadParams) = try { + val currentPage = params.key ?: 1 + var nextPageNumber: Int? = null + var data: List = emptyList() + repository.getFilms(page = currentPage).collect { response -> + nextPageNumber = if (response.next == null) null else currentPage + 1 + data = response.results.map { dto -> + val id = dto.url.extractNumbers() + dto.toDomain(id) + } + } + LoadResult.Page( + data = data, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = nextPageNumber + ) + + } catch (e: Exception) { + LoadResult.Error(e) + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/repository/CharacterDetailsRepositoryImpl.kt b/app/src/main/java/com/example/hurb_challenge/app/data/repository/CharacterDetailsRepositoryImpl.kt new file mode 100644 index 000000000..fe39b75c5 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/repository/CharacterDetailsRepositoryImpl.kt @@ -0,0 +1,42 @@ +package com.example.hurb_challenge.app.data.repository + +import com.example.hurb_challenge.app.core.extractNumbers +import com.example.hurb_challenge.app.data.ApiService +import com.example.hurb_challenge.app.data.Result +import com.example.hurb_challenge.app.data.asResult +import com.example.hurb_challenge.app.domain.model.Starship +import com.example.hurb_challenge.app.domain.model.Vehicle +import com.example.hurb_challenge.app.domain.repository.CharacterDetailsRepository +import com.example.hurb_challenge.app.domain.toDomain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class CharacterDetailsRepositoryImpl @Inject constructor( + private val apiService: ApiService +) : CharacterDetailsRepository { + + override suspend fun getVehicles(urls: List): Flow>> { + return flow { + val vehicles = mutableListOf() + urls.forEach { url -> + val dto = apiService.getVehicle(url) + val id = dto.url.extractNumbers() + vehicles.add(dto.toDomain(id)) + } + emit(vehicles) + }.asResult() + } + + override suspend fun getStarships(urls: List): Flow>> { + return flow { + val starships = mutableListOf() + urls.forEach { url -> + val dto = apiService.getStarship(url) + val id = dto.url.extractNumbers() + starships.add(dto.toDomain(id)) + } + emit(starships) + }.asResult() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/repository/CharactersRepositoryImpl.kt b/app/src/main/java/com/example/hurb_challenge/app/data/repository/CharactersRepositoryImpl.kt new file mode 100644 index 000000000..47cdcb42b --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/repository/CharactersRepositoryImpl.kt @@ -0,0 +1,13 @@ +package com.example.hurb_challenge.app.data.repository + +import com.example.hurb_challenge.app.data.ApiService +import com.example.hurb_challenge.app.domain.repository.CharactersRepository +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class CharactersRepositoryImpl @Inject constructor( + private val apiService: ApiService +) : CharactersRepository { + + override suspend fun getCharactersByPage(page: Int) = flow { emit(apiService.getCharacters(page)) } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/repository/FilmDetailsRepositoryImpl.kt b/app/src/main/java/com/example/hurb_challenge/app/data/repository/FilmDetailsRepositoryImpl.kt new file mode 100644 index 000000000..17fedf632 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/repository/FilmDetailsRepositoryImpl.kt @@ -0,0 +1,29 @@ +package com.example.hurb_challenge.app.data.repository + +import com.example.hurb_challenge.app.core.extractNumbers +import com.example.hurb_challenge.app.data.ApiService +import com.example.hurb_challenge.app.data.Result +import com.example.hurb_challenge.app.data.asResult +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.repository.FilmDetailsRepository +import com.example.hurb_challenge.app.domain.toDomain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class FilmDetailsRepositoryImpl @Inject constructor( + private val apiService: ApiService +) : FilmDetailsRepository { + + override suspend fun getCharactersByUrls(urls: List): Flow>> { + return flow> { + val charactersDto = mutableListOf() + urls.forEach { url -> + val dto = apiService.getCharacter(url) + val id = dto.url.extractNumbers() + charactersDto.add(dto.toDomain(id)) + } + emit(charactersDto) + }.asResult() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/data/repository/FilmsRepositoryImpl.kt b/app/src/main/java/com/example/hurb_challenge/app/data/repository/FilmsRepositoryImpl.kt new file mode 100644 index 000000000..7d37b1259 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/data/repository/FilmsRepositoryImpl.kt @@ -0,0 +1,13 @@ +package com.example.hurb_challenge.app.data.repository + +import com.example.hurb_challenge.app.data.ApiService +import com.example.hurb_challenge.app.domain.repository.FilmsRepository +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class FilmsRepositoryImpl @Inject constructor( + private val apiService: ApiService +) : FilmsRepository { + + override suspend fun getFilms(page: Int) = flow { emit(apiService.getFilms(page)) } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/Mapper.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/Mapper.kt new file mode 100644 index 000000000..05c11c70c --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/Mapper.kt @@ -0,0 +1,55 @@ +package com.example.hurb_challenge.app.domain + +import com.example.hurb_challenge.app.core.encodeUrl +import com.example.hurb_challenge.app.data.dto.CharacterDto +import com.example.hurb_challenge.app.data.dto.FilmDto +import com.example.hurb_challenge.app.data.dto.StarshipDto +import com.example.hurb_challenge.app.data.dto.VehicleDto +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.model.Film +import com.example.hurb_challenge.app.domain.model.Starship +import com.example.hurb_challenge.app.domain.model.Vehicle + +fun CharacterDto.toDomain(id: String) = Character( + id, + name, + height, + gender.encodeUrl(), + birthYear, + vehiclesUrl.encodeUrl(), + starshipsUrl.encodeUrl() +) + +fun FilmDto.toDomain(id: String) = Film( + id, title, episode, openingCrawl, director, releaseDate, charactersUrl.encodeUrl() +) + +fun VehicleDto.toDomain(id: String) = Vehicle(id, name, model) + +fun StarshipDto.toDomain(id: String) = Starship(id, name, model) + +fun Vehicle.toCardItem() = CardItem( + title = name, + subtitle = model, + item = this +) + +fun Starship.toCardItem() = CardItem( + title = name, + subtitle = model, + item = this +) + +fun Film.toCardItem() = CardItem( + title = title, + subtitle = releaseDate, + item = this, +) + +fun Character.toCardItem() = CardItem( + title = name, + subtitle = gender.encodeUrl(), + item = this +) + diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/model/CardItem.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/model/CardItem.kt new file mode 100644 index 000000000..30c317113 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/model/CardItem.kt @@ -0,0 +1,7 @@ +package com.example.hurb_challenge.app.domain.model + +data class CardItem( + val title: String, + val subtitle: String, + val item: T +) diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/model/Character.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/model/Character.kt new file mode 100644 index 000000000..03746f332 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/model/Character.kt @@ -0,0 +1,22 @@ +package com.example.hurb_challenge.app.domain.model + +import android.os.Parcelable +import com.example.hurb_challenge.BuildConfig +import com.example.hurb_challenge.app.core.encodeUrl +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +data class Character( + val id: String, + val name: String, + val height: String, + val gender: String, + val birthYear: String, + val vehiclesUrl: List, + val starshipsUrl: List +): ImageUrlProvider, Parcelable { + + override fun getImageUrl() = "${BuildConfig.BASE_IMAGE_URL}/characters/$id.jpg".encodeUrl() +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/model/Film.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/model/Film.kt new file mode 100644 index 000000000..c2f3ee4ec --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/model/Film.kt @@ -0,0 +1,22 @@ +package com.example.hurb_challenge.app.domain.model + +import android.os.Parcelable +import com.example.hurb_challenge.BuildConfig +import com.example.hurb_challenge.app.core.encodeUrl +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +data class Film( + val id: String, + val title: String, + val episode: Int, + val openingCrawl: String, + val director: String, + val releaseDate: String, + val charactersUrl: List +): ImageUrlProvider, Parcelable { + + override fun getImageUrl() = "${BuildConfig.BASE_IMAGE_URL}/films/$id.jpg".encodeUrl() +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/model/ImageUrlProvider.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/model/ImageUrlProvider.kt new file mode 100644 index 000000000..6b85dfb9b --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/model/ImageUrlProvider.kt @@ -0,0 +1,6 @@ +package com.example.hurb_challenge.app.domain.model + +interface ImageUrlProvider { + + fun getImageUrl(): String +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/model/Starship.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/model/Starship.kt new file mode 100644 index 000000000..a276a7e5c --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/model/Starship.kt @@ -0,0 +1,12 @@ +package com.example.hurb_challenge.app.domain.model + +import com.example.hurb_challenge.BuildConfig + +data class Starship( + val id: String, + val name: String, + val model: String +) : ImageUrlProvider { + override fun getImageUrl() = BuildConfig.BASE_IMAGE_URL+"/starships/"+id+".jpg" +} + diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/model/Vehicle.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/model/Vehicle.kt new file mode 100644 index 000000000..2d5a82d28 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/model/Vehicle.kt @@ -0,0 +1,11 @@ +package com.example.hurb_challenge.app.domain.model + +import com.example.hurb_challenge.BuildConfig + +data class Vehicle( + val id: String, + val name: String, + val model: String +) : ImageUrlProvider { + override fun getImageUrl() = BuildConfig.BASE_IMAGE_URL+"/vehicles/"+id+".jpg" +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/module/DomainModule.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/module/DomainModule.kt new file mode 100644 index 000000000..966276e9b --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/module/DomainModule.kt @@ -0,0 +1,28 @@ +package com.example.hurb_challenge.app.domain.module + +import com.example.hurb_challenge.app.domain.repository.CharactersRepository +import com.example.hurb_challenge.app.domain.repository.FilmsRepository +import com.example.hurb_challenge.app.domain.usecase.GetCharactersUseCase +import com.example.hurb_challenge.app.domain.usecase.GetFilmsUseCase +import com.example.hurb_challenge.app.domain.usecase.HomeUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@InstallIn(SingletonComponent::class) +@Module +object DomainModule { + + @Provides + fun provideGetFilmsUseCase( + repository: FilmsRepository + ): HomeUseCase.GetFilmsUseCase = + GetFilmsUseCase(repository) + + @Provides + fun provideGetCharactersUseCase( + repository: CharactersRepository + ): HomeUseCase.GetCharactersUseCase = + GetCharactersUseCase(repository) +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/repository/CharacterDetailsRepository.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/repository/CharacterDetailsRepository.kt new file mode 100644 index 000000000..6cabff60e --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/repository/CharacterDetailsRepository.kt @@ -0,0 +1,13 @@ +package com.example.hurb_challenge.app.domain.repository + +import com.example.hurb_challenge.app.data.Result +import com.example.hurb_challenge.app.domain.model.Starship +import com.example.hurb_challenge.app.domain.model.Vehicle +import kotlinx.coroutines.flow.Flow + +interface CharacterDetailsRepository { + + suspend fun getVehicles(urls: List): Flow>> + + suspend fun getStarships(urls: List): Flow>> +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/repository/CharactersRepository.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/repository/CharactersRepository.kt new file mode 100644 index 000000000..1e2ec73c7 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/repository/CharactersRepository.kt @@ -0,0 +1,10 @@ +package com.example.hurb_challenge.app.domain.repository + +import com.example.hurb_challenge.app.data.dto.ApiResponse +import com.example.hurb_challenge.app.data.dto.CharacterDto +import kotlinx.coroutines.flow.Flow + +interface CharactersRepository { + + suspend fun getCharactersByPage(page: Int): Flow> +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/repository/FilmDetailsRepository.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/repository/FilmDetailsRepository.kt new file mode 100644 index 000000000..4ce7b6bee --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/repository/FilmDetailsRepository.kt @@ -0,0 +1,10 @@ +package com.example.hurb_challenge.app.domain.repository + +import com.example.hurb_challenge.app.data.Result +import com.example.hurb_challenge.app.domain.model.Character +import kotlinx.coroutines.flow.Flow + +interface FilmDetailsRepository { + + suspend fun getCharactersByUrls(urls: List): Flow>> +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/repository/FilmsRepository.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/repository/FilmsRepository.kt new file mode 100644 index 000000000..5e7343577 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/repository/FilmsRepository.kt @@ -0,0 +1,9 @@ +package com.example.hurb_challenge.app.domain.repository + +import com.example.hurb_challenge.app.data.dto.ApiResponse +import com.example.hurb_challenge.app.data.dto.FilmDto +import kotlinx.coroutines.flow.Flow + +interface FilmsRepository { + suspend fun getFilms(page: Int): Flow> +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharacterStarshipsUseCase.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharacterStarshipsUseCase.kt new file mode 100644 index 000000000..220be86da --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharacterStarshipsUseCase.kt @@ -0,0 +1,10 @@ +package com.example.hurb_challenge.app.domain.usecase + +import com.example.hurb_challenge.app.domain.repository.CharacterDetailsRepository +import javax.inject.Inject + +class GetCharacterStarshipsUseCase @Inject constructor( + private val repository: CharacterDetailsRepository +) { + suspend fun call(urls: List) = repository.getStarships(urls) +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharacterVehiclesUseCase.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharacterVehiclesUseCase.kt new file mode 100644 index 000000000..cdbed3fe5 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharacterVehiclesUseCase.kt @@ -0,0 +1,11 @@ +package com.example.hurb_challenge.app.domain.usecase + +import com.example.hurb_challenge.app.domain.repository.CharacterDetailsRepository +import javax.inject.Inject + +class GetCharacterVehiclesUseCase @Inject constructor( + private val repository: CharacterDetailsRepository +) { + + suspend fun call(urls: List) = repository.getVehicles(urls) +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharactersInFilmUseCase.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharactersInFilmUseCase.kt new file mode 100644 index 000000000..a5285b9ac --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharactersInFilmUseCase.kt @@ -0,0 +1,14 @@ +package com.example.hurb_challenge.app.domain.usecase + +import com.example.hurb_challenge.app.data.Result +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.repository.FilmDetailsRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetCharactersInFilmUseCase @Inject constructor( + private val repository: FilmDetailsRepository +) { + suspend fun call(urls: List): Flow>> = + repository.getCharactersByUrls(urls) +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharactersUseCase.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharactersUseCase.kt new file mode 100644 index 000000000..fe83e569f --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetCharactersUseCase.kt @@ -0,0 +1,24 @@ +package com.example.hurb_challenge.app.domain.usecase + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.map +import com.example.hurb_challenge.app.data.pagingsource.CharactersPagingSource +import com.example.hurb_challenge.app.domain.repository.CharactersRepository +import com.example.hurb_challenge.app.domain.toCardItem +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class GetCharactersUseCase @Inject constructor( + private val repository: CharactersRepository +) : HomeUseCase.GetCharactersUseCase { + override fun call() = + Pager( + config = PagingConfig(HomeUseCase.PAGE_SIZE, HomeUseCase.PREFETCH_DISTANCE), + pagingSourceFactory = { + CharactersPagingSource(repository) + } + ).flow.map { pagingData -> + pagingData.map { character -> character.toCardItem() } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetFilmsUseCase.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetFilmsUseCase.kt new file mode 100644 index 000000000..838a94984 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/GetFilmsUseCase.kt @@ -0,0 +1,24 @@ +package com.example.hurb_challenge.app.domain.usecase + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.map +import com.example.hurb_challenge.app.data.pagingsource.FilmsPagingSource +import com.example.hurb_challenge.app.domain.repository.FilmsRepository +import com.example.hurb_challenge.app.domain.toCardItem +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class GetFilmsUseCase @Inject constructor( + private val repository: FilmsRepository +) : HomeUseCase.GetFilmsUseCase { + + override fun call() = Pager( + config = PagingConfig(HomeUseCase.PAGE_SIZE, HomeUseCase.PREFETCH_DISTANCE), + pagingSourceFactory = { + FilmsPagingSource(repository) + } + ).flow.map { pagingData -> + pagingData.map { film -> film.toCardItem() } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/HomeUseCase.kt b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/HomeUseCase.kt new file mode 100644 index 000000000..5f7f14f5c --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/domain/usecase/HomeUseCase.kt @@ -0,0 +1,17 @@ +package com.example.hurb_challenge.app.domain.usecase + +import androidx.paging.PagingData +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.model.Film +import com.example.hurb_challenge.app.domain.model.CardItem +import kotlinx.coroutines.flow.Flow + +sealed interface HomeUseCase { + companion object { + const val PAGE_SIZE = 10 + const val PREFETCH_DISTANCE = 4 + } + fun call(): Flow>> + interface GetFilmsUseCase : HomeUseCase + interface GetCharactersUseCase : HomeUseCase +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/MainActivity.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/MainActivity.kt new file mode 100644 index 000000000..cc738b214 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/MainActivity.kt @@ -0,0 +1,121 @@ +package com.example.hurb_challenge.app.presentation + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import androidx.paging.compose.collectAsLazyPagingItems +import com.example.hurb_challenge.app.core.serializableType +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.model.Film +import com.example.hurb_challenge.app.presentation.characterdetails.CharacterDetailsScreen +import com.example.hurb_challenge.app.presentation.characterdetails.CharacterDetailsViewModel +import com.example.hurb_challenge.app.presentation.theme.HurbChallengeTheme +import com.example.hurb_challenge.app.presentation.home.CharactersViewModel +import com.example.hurb_challenge.app.presentation.common.BottomNavItem +import com.example.hurb_challenge.app.presentation.common.Destination +import com.example.hurb_challenge.app.presentation.common.composable.BottomNavigationBar +import com.example.hurb_challenge.app.presentation.filmdetails.FilmDetailsScreen +import com.example.hurb_challenge.app.presentation.filmdetails.FilmDetailsViewModel +import com.example.hurb_challenge.app.presentation.home.HomeSectionScreen +import com.example.hurb_challenge.app.presentation.home.films.FilmsViewModel +import dagger.hilt.android.AndroidEntryPoint +import kotlin.reflect.typeOf + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + installSplashScreen() + + setContent { + HurbChallengeTheme { + val navBarItems = listOf(BottomNavItem.Movies, BottomNavItem.Characters) + val navController = rememberNavController() + + BackHandler { + navController.popBackStack() + } + + Scaffold( + bottomBar = { + BottomNavigationBar(navBarItems, navController) + } + ) { innerPadding -> + NavHost(navController = navController, startDestination = Destination.Films) { + composable { + val viewModel: FilmsViewModel = hiltViewModel() + val cardItem = viewModel.cardItemStateFlow.collectAsLazyPagingItems() + HomeSectionScreen( + cardItem = cardItem, + onRetry = { viewModel.loadCardItems() }, + onItemClicked = { film -> + navController.navigate( + Destination.FilmDetails(film) + ) }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) + } + + composable { + val viewModel: CharactersViewModel = hiltViewModel() + val cardItem = viewModel.cardItemStateFlow.collectAsLazyPagingItems() + HomeSectionScreen( + cardItem = cardItem, + onRetry = { viewModel.loadCardItems() }, + onItemClicked = { character -> + navController.navigate( + Destination.CharactersDetails(character) + ) }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) + } + + composable( + typeMap = mapOf(typeOf() to serializableType()) + ) { + val film = it.toRoute().film + val viewModel: FilmDetailsViewModel = hiltViewModel() + FilmDetailsScreen( + film = film, + viewModel = viewModel, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + onRetry = { viewModel.loadCharactersByUrl(film.charactersUrl) } + ) + } + + composable( + typeMap = mapOf(typeOf() to serializableType()) + ) { + val character = it.toRoute().character + val viewModel: CharacterDetailsViewModel = hiltViewModel() + CharacterDetailsScreen( + character = character, + viewModel = viewModel, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/characterdetails/CharacterDetailsScreen.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/characterdetails/CharacterDetailsScreen.kt new file mode 100644 index 000000000..8205a5526 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/characterdetails/CharacterDetailsScreen.kt @@ -0,0 +1,251 @@ +package com.example.hurb_challenge.app.presentation.characterdetails + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.hurb_challenge.R +import com.example.hurb_challenge.app.core.decodeUrl +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.model.Starship +import com.example.hurb_challenge.app.domain.model.Vehicle +import com.example.hurb_challenge.app.presentation.common.State +import com.example.hurb_challenge.app.presentation.common.Error +import com.example.hurb_challenge.app.presentation.common.Loaded +import com.example.hurb_challenge.app.presentation.common.Loading +import com.example.hurb_challenge.app.presentation.common.composable.CircularProgress +import com.example.hurb_challenge.app.presentation.common.composable.AsyncImageLoader +import com.example.hurb_challenge.app.presentation.common.composable.Card +import com.example.hurb_challenge.app.presentation.common.composable.ErrorMessage +import com.example.hurb_challenge.app.presentation.common.composable.TextDescription + +@Composable +fun CharacterDetailsScreen( + character: Character, + viewModel: CharacterDetailsViewModel, + modifier: Modifier = Modifier +) { + LaunchedEffect(key1 = viewModel) { + viewModel.loadCharacterVehicles(character.vehiclesUrl) + viewModel.loadCharacterStarships(character.starshipsUrl) + } + + val scrollState = rememberScrollState() + val vehiclesState = viewModel.characterVehiclesStateFlow.collectAsState() + val starshipsState = viewModel.characterStarshipsStateFlow.collectAsState() + + Box(modifier) { + Column(modifier = Modifier.verticalScroll(scrollState)) { + ImageSection(url = character.getImageUrl()) + CharacterDetailsSection(character = character) + Spacer(modifier = Modifier.padding(vertical = 12.dp)) + CharacterVehicles( + state = vehiclesState.value, + onRetry = { viewModel.loadCharacterVehicles(character.vehiclesUrl) } + ) + Spacer(modifier = Modifier.padding(vertical = 12.dp)) + CharacterStarships( + state = starshipsState.value, + onRetry = { viewModel.loadCharacterStarships(character.starshipsUrl) } + ) + } + } +} + +@Composable +fun ImageSection(url: String) { + AsyncImageLoader( + url = url, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.5f), + loadingContent = { + CircularProgress( + Modifier + .size(60.dp) + .align(Alignment.Center) + ) + }, + failureContent = { + Image( + painter = painterResource(id = R.drawable.ic_img_placeholder), + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth(), + contentDescription = null + ) + }, + loadedContent = { painter -> + Image( + painter = painter, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth(), + contentDescription = null + ) + } + ) +} + +@Composable +fun CharacterDetailsSection(character: Character) { + TextDescription( + title = stringResource(id = R.string.character_name), + subtitle = character.name + ) + TextDescription( + title = stringResource(id = R.string.character_gender), + subtitle = character.gender.decodeUrl() + ) + TextDescription( + title = stringResource(id = R.string.character_birth_year), + subtitle = character.birthYear + ) + TextDescription( + title = stringResource(id = R.string.character_height), + subtitle = "${character.height} cm" + ) +} + +@Composable +fun CharacterVehicles( + state: State>>, + onRetry: () -> Unit +) { + Box { + Column(Modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.vehicles), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 10.dp), + fontSize = 16.sp, + color = Color(0xFF818181), + style = MaterialTheme.typography.titleMedium + ) + when (state) { + is Loaded -> { + if (state.value.isEmpty()) { + Text( + text = stringResource(id = R.string.no_item_found), + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 10.dp) + .align(Alignment.CenterHorizontally), + fontSize = 16.sp, + color = Color.Black, + style = MaterialTheme.typography.titleMedium + ) + } else { + LazyRow( + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.SpaceAround, + ) { + val items = state.value + items(items) { cardItem -> + Card(cardItem = cardItem, onItemClicked = {}) + } + } + } + } + is Error -> { + ErrorMessage( + message = state.message, + modifier = Modifier.padding(vertical = 8.dp), + onClick = onRetry::invoke + ) + } + is Loading -> { + CircularProgress( + Modifier + .fillMaxSize() + .padding(vertical = 10.dp) + .align(Alignment.CenterHorizontally) + ) + } + else -> { } + } + } + } +} + +@Composable +fun CharacterStarships( + state: State>>, + onRetry: () -> Unit +) { + Box { + Column(Modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.starships), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 10.dp), + fontSize = 16.sp, + color = Color(0xFF818181), + style = MaterialTheme.typography.titleMedium + ) + when (state) { + is Loaded -> { + if (state.value.isEmpty()) { + Text( + text = stringResource(id = R.string.no_item_found), + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 10.dp) + .align(Alignment.CenterHorizontally), + fontSize = 16.sp, + color = Color.Black, + style = MaterialTheme.typography.titleMedium + ) + } else { + LazyRow( + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.SpaceAround, + ) { + val items = state.value + items(items) { cardItem -> + Card(cardItem = cardItem, onItemClicked = {}) + } + } + } + } + is Error -> { + ErrorMessage( + message = state.message, + modifier = Modifier.padding(vertical = 8.dp), + onClick = onRetry::invoke + ) + } + is Loading -> { + CircularProgress( + Modifier + .fillMaxSize() + .padding(vertical = 10.dp) + .align(Alignment.CenterHorizontally) + ) + } + else -> { } + } + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/characterdetails/CharacterDetailsViewModel.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/characterdetails/CharacterDetailsViewModel.kt new file mode 100644 index 000000000..c1a0609a3 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/characterdetails/CharacterDetailsViewModel.kt @@ -0,0 +1,93 @@ +package com.example.hurb_challenge.app.presentation.characterdetails + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.hurb_challenge.app.data.ConnectionFailure +import com.example.hurb_challenge.app.data.Failure +import com.example.hurb_challenge.app.data.Fetching +import com.example.hurb_challenge.app.data.HttpFailure +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.domain.model.Starship +import com.example.hurb_challenge.app.domain.model.Vehicle +import com.example.hurb_challenge.app.domain.toCardItem +import com.example.hurb_challenge.app.domain.usecase.GetCharacterStarshipsUseCase +import com.example.hurb_challenge.app.domain.usecase.GetCharacterVehiclesUseCase +import com.example.hurb_challenge.app.presentation.common.Empty +import com.example.hurb_challenge.app.presentation.common.Error +import com.example.hurb_challenge.app.presentation.common.Loaded +import com.example.hurb_challenge.app.presentation.common.Loading +import com.example.hurb_challenge.app.presentation.common.State +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CharacterDetailsViewModel @Inject constructor( + private val getVehiclesUseCase: GetCharacterVehiclesUseCase, + private val getStarshipsUseCase: GetCharacterStarshipsUseCase, + private val dispatcher: CoroutineDispatcher +) : ViewModel() { + + private val _characterVehiclesStateFlow: MutableStateFlow>>> = + MutableStateFlow(Empty) + + private val _characterStarshipsStateFlow: MutableStateFlow>>> = + MutableStateFlow(Empty) + + val characterStarshipsStateFlow = _characterStarshipsStateFlow.asStateFlow() + val characterVehiclesStateFlow = _characterVehiclesStateFlow.asStateFlow() + + fun loadCharacterVehicles(urls: List) { + viewModelScope.launch(dispatcher) { + getVehiclesUseCase.call(urls) + .collect { result -> + when (result) { + is Fetching -> { + _characterVehiclesStateFlow.emit(Loading) + } + is Failure -> { + val message = when (result) { + is HttpFailure -> "Http error: ${result.errorCode}" + is ConnectionFailure -> "Connectivity Error" + else -> "Unknown error" + } + _characterVehiclesStateFlow.emit(Error(message)) + } + is Success -> { + val vehicles = result.value.map { it.toCardItem() } + _characterVehiclesStateFlow.emit(Loaded(vehicles)) + } + } + } + } + } + + fun loadCharacterStarships(urls: List) { + viewModelScope.launch(dispatcher) { + getStarshipsUseCase.call(urls) + .collect { result -> + when (result) { + is Fetching -> { + _characterStarshipsStateFlow.emit(Loading) + } + is Failure -> { + val message = when (result) { + is HttpFailure -> "Http error: ${result.errorCode}" + is ConnectionFailure -> "Connectivity Error" + else -> "Unknown error" + } + _characterStarshipsStateFlow.emit(Error(message)) + } + is Success -> { + val starships = result.value.map { it.toCardItem() } + _characterStarshipsStateFlow.emit(Loaded(starships)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/BottomNavItem.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/BottomNavItem.kt new file mode 100644 index 000000000..2b57ffae0 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/BottomNavItem.kt @@ -0,0 +1,30 @@ +package com.example.hurb_challenge.app.presentation.common + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.Person +import androidx.compose.ui.graphics.vector.ImageVector +import com.example.hurb_challenge.R + +sealed class BottomNavItem( + val route: Destination, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val label: Int +) { + + data object Movies : BottomNavItem( + route = Destination.Films, + selectedIcon = Icons.Filled.Movie, + unselectedIcon = Icons.Outlined.Movie, + label = R.string.bottom_nav_film_label + ) + data object Characters : BottomNavItem( + route = Destination.Characters, + selectedIcon = Icons.Filled.Person, + unselectedIcon = Icons.Outlined.Person, + label = R.string.bottom_nav_characters_label + ) +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/Destination.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/Destination.kt new file mode 100644 index 000000000..7595d1b1b --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/Destination.kt @@ -0,0 +1,24 @@ +package com.example.hurb_challenge.app.presentation.common + +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.model.Film +import kotlinx.serialization.Serializable + +sealed class Destination { + + @Serializable + data object Films : Destination() + + @Serializable + data object Characters : Destination() + + @Serializable + data class FilmDetails( + val film: Film + ) : Destination() + + @Serializable + data class CharactersDetails( + val character: Character + ) : Destination() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/State.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/State.kt new file mode 100644 index 000000000..bef7138fd --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/State.kt @@ -0,0 +1,14 @@ +package com.example.hurb_challenge.app.presentation.common + +sealed class State + +data object Empty : State() +data object Loading : State() + +data class Error( + val message: String +) : State() + +data class Loaded( + val value: T +) : State() \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/AsyncImageLoader.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/AsyncImageLoader.kt new file mode 100644 index 000000000..ea7974732 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/AsyncImageLoader.kt @@ -0,0 +1,35 @@ +package com.example.hurb_challenge.app.presentation.common.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.example.hurb_challenge.app.core.decodeUrl + +@Composable +fun AsyncImageLoader( + url: String, + modifier: Modifier = Modifier, + loadingContent: @Composable BoxScope.() -> Unit, + failureContent: @Composable BoxScope.() -> Unit, + loadedContent: @Composable BoxScope.(AsyncImagePainter) -> Unit + ) { + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(url.decodeUrl()) + .size(coil.size.Size.ORIGINAL) + .build() + ) + + Box(modifier = modifier) { + when (painter.state) { + is AsyncImagePainter.State.Loading -> { loadingContent() } + is AsyncImagePainter.State.Error -> { failureContent() } + else -> { loadedContent(painter) } + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/BottomNavigation.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/BottomNavigation.kt new file mode 100644 index 000000000..dc51f6987 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/BottomNavigation.kt @@ -0,0 +1,45 @@ +package com.example.hurb_challenge.app.presentation.common.composable + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import com.example.hurb_challenge.R +import com.example.hurb_challenge.app.presentation.common.BottomNavItem + +@Composable +fun BottomNavigationBar(tabBarItems: List, navController: NavController) { + var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } + + NavigationBar(modifier = Modifier.border(BorderStroke(width = 1.dp, Color.DarkGray))) { + + tabBarItems.forEachIndexed { index, navBarItem -> + val isSelected = selectedTabIndex == index + NavigationBarItem( + selected = isSelected, + onClick = { + selectedTabIndex = index + navController.navigate(navBarItem.route) + }, + icon = { + val icon = if (isSelected) navBarItem.selectedIcon else navBarItem.unselectedIcon + Icon(imageVector = icon, contentDescription = stringResource(id = navBarItem.label)) + }, + label = { Text(text = stringResource(id = navBarItem.label)) } + ) + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/Card.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/Card.kt new file mode 100644 index 000000000..0d72c914c --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/Card.kt @@ -0,0 +1,89 @@ +package com.example.hurb_challenge.app.presentation.common.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.hurb_challenge.R +import com.example.hurb_challenge.app.core.decodeUrl +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.domain.model.ImageUrlProvider + +@Composable +fun Card(cardItem: CardItem, onItemClicked: (T) -> Unit) { + Card( + modifier = + Modifier + .padding(6.dp) + .border(1.dp, Color.LightGray, RoundedCornerShape(10.dp)) + .clickable { onItemClicked.invoke(cardItem.item) } + ) { + Column(Modifier.background(Color.White)) { + AsyncImageLoader( + url = (cardItem.item as? ImageUrlProvider)?.getImageUrl() ?: "", + modifier = Modifier + .fillMaxWidth() + .size(180.dp), + loadingContent = { + CircularProgress( + Modifier + .size(60.dp) + .align(Alignment.Center) + ) + }, + failureContent = { + Image( + painter = painterResource(id = R.drawable.ic_img_placeholder), + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth(), + contentDescription = null + ) + }, + loadedContent = { painter -> + Image( + painter = painter, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth(), + contentDescription = null + ) + } + ) + Text( + cardItem.title, + fontSize = 16.sp, + color = Color.DarkGray, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 4.dp), + style = MaterialTheme.typography.titleLarge, + ) + Text( + cardItem.subtitle.decodeUrl(), + fontSize = 13.sp, + color = Color.Black, + modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp, bottom = 6.dp, top = 2.dp), + style = MaterialTheme.typography.titleMedium, + ) + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/CircularProgress.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/CircularProgress.kt new file mode 100644 index 000000000..95887fc98 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/CircularProgress.kt @@ -0,0 +1,17 @@ +package com.example.hurb_challenge.app.presentation.common.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun CircularProgress(modifier: Modifier = Modifier) { + Box(modifier) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + ) + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/ErrorMessage.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/ErrorMessage.kt new file mode 100644 index 000000000..57ece6bcb --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/ErrorMessage.kt @@ -0,0 +1,54 @@ +package com.example.hurb_challenge.app.presentation.common.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.hurb_challenge.R + +@Composable +fun ErrorMessage(message: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize(1f) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = message, + modifier = modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 18.sp, + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(18.dp)) + Button( + onClick = onClick::invoke, + modifier = Modifier + .align(Alignment.CenterHorizontally) + ) { + Text( + text = stringResource(id = R.string.retry), + modifier = Modifier + .padding(7.dp) + .fillMaxWidth(0.5f), + textAlign = TextAlign.Center, + fontSize = 16.sp, + style = MaterialTheme.typography.titleLarge + ) + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/TextDescription.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/TextDescription.kt new file mode 100644 index 000000000..d388b80ec --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/common/composable/TextDescription.kt @@ -0,0 +1,41 @@ +package com.example.hurb_challenge.app.presentation.common.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun TextDescription(title: String, subtitle: String) { + Column( + modifier = Modifier + .fillMaxWidth(), + Arrangement.SpaceBetween + ) { + Text( + text = title, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 6.dp), + fontSize = 16.sp, + color = Color(0xFF818181), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = subtitle, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 2.dp), + fontSize = 14.sp, + color = Color.Black, + style = MaterialTheme.typography.titleMedium, + ) + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/filmdetails/FilmDetailsScreen.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/filmdetails/FilmDetailsScreen.kt new file mode 100644 index 000000000..45aa720a2 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/filmdetails/FilmDetailsScreen.kt @@ -0,0 +1,174 @@ +package com.example.hurb_challenge.app.presentation.filmdetails + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.hurb_challenge.R +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.model.Film +import com.example.hurb_challenge.app.presentation.common.composable.AsyncImageLoader +import com.example.hurb_challenge.app.presentation.common.composable.Card +import com.example.hurb_challenge.app.presentation.common.Error +import com.example.hurb_challenge.app.presentation.common.composable.ErrorMessage +import com.example.hurb_challenge.app.presentation.common.Loaded +import com.example.hurb_challenge.app.presentation.common.Loading +import com.example.hurb_challenge.app.presentation.common.composable.CircularProgress +import com.example.hurb_challenge.app.presentation.common.State +import com.example.hurb_challenge.app.presentation.common.composable.TextDescription + +@Composable +fun FilmDetailsScreen( + film: Film, + viewModel: FilmDetailsViewModel, + modifier: Modifier, + onRetry: () -> Unit +) { + LaunchedEffect(key1 = viewModel) { + viewModel.loadCharactersByUrl(film.charactersUrl) + } + val scrollState = rememberScrollState() + val state by viewModel.charactersStateFlow.collectAsState() + + Box(modifier = modifier) { + Column(modifier = Modifier.verticalScroll(scrollState)) { + ImageSection(url = film.getImageUrl()) + FilmDetailsSection(film = film) + Spacer(modifier = Modifier.padding(vertical = 12.dp)) + FilmCharactersSection( + state = state, + onRetry = onRetry::invoke + ) + } + } +} + +@Composable +fun FilmDetailsSection(film: Film) { + TextDescription( + title = stringResource(id = R.string.movie_title), + subtitle = film.title + ) + TextDescription( + title = stringResource(id = R.string.episode_number), + subtitle = film.episode.toString() + ) + TextDescription( + title = stringResource(id = R.string.directed_by), + subtitle = film.director + ) + TextDescription( + title = stringResource(id = R.string.release_date), + subtitle = film.releaseDate + ) + TextDescription( + title = stringResource(id = R.string.opening_crawl), + subtitle = film.openingCrawl + ) +} + +@Composable +fun ImageSection(url: String) { + AsyncImageLoader( + url = url, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.5f), + loadingContent = { + CircularProgress(modifier = Modifier + .size(60.dp) + .align(Alignment.Center) + ) + }, + failureContent = { + Image( + painter = painterResource(id = R.drawable.ic_img_placeholder), + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth(), + contentDescription = null + ) + }, + loadedContent = { painter -> + Image( + painter = painter, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth(), + contentDescription = null + ) + } + ) +} + +@Composable +fun FilmCharactersSection( + state: State>>, + onRetry: () -> Unit + ) { + Box { + Column { + Text( + text = stringResource(id = R.string.characters), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 10.dp), + fontSize = 16.sp, + color = Color(0xFF818181), + style = MaterialTheme.typography.titleMedium + ) + when (state) { + is Loaded -> { + LazyRow( + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.SpaceAround, + ) { + val items = state.value + items(items.size) { index -> + Card(cardItem = items[index], onItemClicked = {}) + } + } + } + is Error -> { + ErrorMessage( + message = state.message, + modifier = Modifier.padding(vertical = 8.dp), + onClick = onRetry::invoke + ) + } + is Loading -> { + CircularProgress( + Modifier + .fillMaxSize() + .padding(vertical = 10.dp) + .align(Alignment.CenterHorizontally) + ) + } + else -> { } + } + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/filmdetails/FilmDetailsViewModel.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/filmdetails/FilmDetailsViewModel.kt new file mode 100644 index 000000000..ee7ca2d8f --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/filmdetails/FilmDetailsViewModel.kt @@ -0,0 +1,65 @@ +package com.example.hurb_challenge.app.presentation.filmdetails + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.hurb_challenge.app.data.ConnectionFailure +import com.example.hurb_challenge.app.data.Failure +import com.example.hurb_challenge.app.data.Fetching +import com.example.hurb_challenge.app.data.HttpFailure +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.toCardItem +import com.example.hurb_challenge.app.domain.usecase.GetCharactersInFilmUseCase +import com.example.hurb_challenge.app.presentation.common.Empty +import com.example.hurb_challenge.app.presentation.common.Error +import com.example.hurb_challenge.app.presentation.common.Loaded +import com.example.hurb_challenge.app.presentation.common.Loading +import com.example.hurb_challenge.app.presentation.common.State +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FilmDetailsViewModel @Inject constructor( + private val useCase: GetCharactersInFilmUseCase, + private val dispatcher: CoroutineDispatcher +) : ViewModel() { + + private val _charactersStateFlow: MutableStateFlow>>> = MutableStateFlow( + Empty + ) + val charactersStateFlow: StateFlow>>> = _charactersStateFlow.asStateFlow() + + fun loadCharactersByUrl(urls: List) { + viewModelScope.launch(dispatcher) { + useCase.call(urls) + .collect { result -> + when (result) { + is Fetching -> { + _charactersStateFlow.emit(Loading) + } + is Failure -> { + var message = "" + message = when (result) { + is HttpFailure -> "Http error: ${result.errorCode}" + is ConnectionFailure -> "Connectivity Error" + else -> "Unknown error" + } + _charactersStateFlow.emit(Error(message)) + } + is Success -> { + val items = result.value.map { character -> + character.toCardItem() + } + _charactersStateFlow.emit(Loaded(items)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/home/CharactersViewModel.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/home/CharactersViewModel.kt new file mode 100644 index 000000000..5d1066282 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/home/CharactersViewModel.kt @@ -0,0 +1,18 @@ +package com.example.hurb_challenge.app.presentation.home + +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.usecase.GetCharactersUseCase +import com.example.hurb_challenge.app.presentation.home.HomeBaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +@HiltViewModel +class CharactersViewModel @Inject constructor( + useCase: GetCharactersUseCase, + dispatcher: CoroutineDispatcher +) : HomeBaseViewModel(useCase, dispatcher) { + init { + loadCardItems() + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/home/HomeBaseViewModel.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/home/HomeBaseViewModel.kt new file mode 100644 index 000000000..02c97780b --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/home/HomeBaseViewModel.kt @@ -0,0 +1,32 @@ +package com.example.hurb_challenge.app.presentation.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.domain.usecase.HomeUseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +open class HomeBaseViewModel( + private val useCase: HomeUseCase, + private val dispatcher: CoroutineDispatcher +) : ViewModel() { + + private val _cardItemStateFlow = MutableStateFlow>>(PagingData.empty()) + val cardItemStateFlow: StateFlow>> = _cardItemStateFlow.asStateFlow() + + fun loadCardItems() { + viewModelScope.launch(dispatcher) { + useCase.call() + .cachedIn(viewModelScope) + .collect { cardItems -> + _cardItemStateFlow.emit(cardItems) + } + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/home/HomeSectionScreen.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/home/HomeSectionScreen.kt new file mode 100644 index 000000000..d8b97f326 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/home/HomeSectionScreen.kt @@ -0,0 +1,77 @@ +package com.example.hurb_challenge.app.presentation.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.presentation.common.composable.CircularProgress +import com.example.hurb_challenge.app.presentation.common.composable.Card +import com.example.hurb_challenge.app.presentation.common.composable.ErrorMessage + +@Composable +fun HomeSectionScreen( + cardItem: LazyPagingItems>, + onRetry: () -> Unit, + onItemClicked: (T) -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + cardItem.apply { + when (loadState.refresh) { + is LoadState.Loading -> { + CircularProgress( + Modifier + .align(Alignment.Center) + .size(50.dp) + ) + } + + is LoadState.Error -> { + val error = cardItem.loadState.refresh as LoadState.Error + ErrorMessage( + message = error.error.localizedMessage ?: "Unknown Error", + onClick = onRetry, + modifier = Modifier + .align(Alignment.Center) + ) + } + + else -> { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.SpaceAround, + ) { + items(cardItem.itemCount) {index -> + Card( + cardItem = cardItem[index]!!, + onItemClicked = onItemClicked + ) + } + if (cardItem.loadState.append is LoadState.Loading) { + item(span = { + GridItemSpan(2) + }) { + CircularProgress( + Modifier.fillMaxSize() + .align(Alignment.Center) + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/home/films/FilmsViewModel.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/home/films/FilmsViewModel.kt new file mode 100644 index 000000000..d8151d91e --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/home/films/FilmsViewModel.kt @@ -0,0 +1,19 @@ +package com.example.hurb_challenge.app.presentation.home.films + +import com.example.hurb_challenge.app.domain.model.Film +import com.example.hurb_challenge.app.domain.usecase.GetFilmsUseCase +import com.example.hurb_challenge.app.presentation.home.HomeBaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +@HiltViewModel +class FilmsViewModel @Inject constructor( + useCase: GetFilmsUseCase, + dispatcher: CoroutineDispatcher +) : HomeBaseViewModel(useCase, dispatcher) { + + init { + loadCardItems() + } +} diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Color.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Color.kt new file mode 100644 index 000000000..afab27ac1 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Color.kt @@ -0,0 +1,12 @@ +package com.example.hurb_challenge.app.presentation.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val LightGray = Color(0xFFCCCCCC) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Theme.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Theme.kt new file mode 100644 index 000000000..56a503a33 --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Theme.kt @@ -0,0 +1,70 @@ +package com.example.hurb_challenge.app.presentation.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun HurbChallengeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Type.kt b/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Type.kt new file mode 100644 index 000000000..a476f709a --- /dev/null +++ b/app/src/main/java/com/example/hurb_challenge/app/presentation/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.hurb_challenge.app.presentation.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/animator/logo_animator.xml b/app/src/main/res/animator/logo_animator.xml new file mode 100644 index 000000000..d68249cbf --- /dev/null +++ b/app/src/main/res/animator/logo_animator.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/animated_logo.xml b/app/src/main/res/drawable/animated_logo.xml new file mode 100644 index 000000000..48c3ec8cf --- /dev/null +++ b/app/src/main/res/drawable/animated_logo.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_img_placeholder.png b/app/src/main/res/drawable/ic_img_placeholder.png new file mode 100644 index 000000000..91243bafb Binary files /dev/null and b/app/src/main/res/drawable/ic_img_placeholder.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/star_wars_logo.xml b/app/src/main/res/drawable/star_wars_logo.xml new file mode 100644 index 000000000..5c2ef54d8 --- /dev/null +++ b/app/src/main/res/drawable/star_wars_logo.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..8f0747197 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + Hurb Challenge + Films + Characters + Retry + Movie Title + Directed By + Episode Number + Release Date + Name + Gender + Birth Year + Height + Opening Crawl + Characters + Vehicles + Starships + No item found + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..a13099931 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 000000000..fa0f996d2 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..9ee9997b0 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/Mocks.kt b/app/src/test/java/com/example/hurb_challenge/Mocks.kt new file mode 100644 index 000000000..ff0a43c7e --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/Mocks.kt @@ -0,0 +1,188 @@ +package com.example.hurb_challenge + +import com.example.hurb_challenge.app.data.dto.ApiResponse +import com.example.hurb_challenge.app.data.dto.CharacterDto +import com.example.hurb_challenge.app.data.dto.FilmDto +import com.example.hurb_challenge.app.data.dto.StarshipDto +import com.example.hurb_challenge.app.data.dto.VehicleDto +import com.example.hurb_challenge.app.domain.model.CardItem +import com.example.hurb_challenge.app.domain.model.Character +import com.example.hurb_challenge.app.domain.model.Starship +import com.example.hurb_challenge.app.domain.model.Vehicle + +object Mocks { + + val apiResponseFilmDto = ApiResponse( + count = 10, + next = "http://mockurl.com", + previous = null, + results = listOf( + FilmDto( + title = "Fake Title", + episode = 1, + openingCrawl = "Fake Opening Crawl", + director = "Fake Director", + url = "http://fakeurl.com", + releaseDate = "1988-05-22", + charactersUrl = listOf( + "http://fakecharacterurl.com/1", + "http://fakecharacterurl.com/2", + "http://fakecharacterurl1.com/3" + ) + ) + ) + ) + + val listOfVehicleDto = listOf( + VehicleDto( + name = "Fake Vehicle Name 01", + model = "Fake Vehicle Model 01", + url = "http://fakevehicleurl.com/01.jpg" + ), + VehicleDto( + name = "Fake Vehicle Name 02", + model = "Fake Vehicle Model 02", + url = "http://fakevehicleurl.com/02.jpg" + ), + VehicleDto( + name = "Fake Vehicle Name 03", + model = "Fake Vehicle Model 03", + url = "http://fakevehicleurl.com/03.jpg" + ) + ) + + val listOfStarshipsDto = listOf( + StarshipDto( + name = "Fake Starship Name 01", + model = "Fake Starship Model 01", + url = "http://fakestarshipurl.com/01.jpg" + ), + StarshipDto( + name = "Fake Starship Name 02", + model = "Fake Starship Model 02", + url = "http://fakestarshipurl.com/02.jpg" + ), + StarshipDto( + name = "Fake Starship Name 03", + model = "Fake Starship Model 03", + url = "http://fakestarshipurl.com/03.jpg" + ) + ) + + val listOfCharactersDto = listOf( + CharacterDto( + name = "Fake Name 01", + height = "200", + gender = "male", + url = "http://fakecharacterurl.com/01", + birthYear = "DY695", + vehiclesUrl = listOf( + "http://fakevehicleurl.com/01", + "http://fakevehicleurl.com/02", + "http://fakevehicleurl.com/03" + ), + starshipsUrl = listOf( + "http://fakestarshipurl.com/01", + "http://fakestarshipurl.com/02", + "http://fakestarshipurl.com/03" + ) + ), + CharacterDto( + name = "Fake Name 02", + height = "162", + gender = "female", + url = "http://fakecharacterurl.com/02", + birthYear = "BN6943", + vehiclesUrl = listOf( + "http://fakevehicleurl.com/05", + "http://fakevehicleurl.com/07", + "http://fakevehicleurl.com/10" + ), + starshipsUrl = listOf( + "http://fakestarshipurl.com/11", + "http://fakestarshipurl.com/12", + "http://fakestarshipurl.com/30" + ) + ), + CharacterDto( + name = "Fake Name 03", + height = "185", + gender = "n/a", + url = "http://fakecharacterurl.com/03", + birthYear = "DY1236", + vehiclesUrl = listOf( + "http://fakevehicleurl.com/13", + "http://fakevehicleurl.com/09", + "http://fakevehicleurl.com/10" + ), + starshipsUrl = listOf( + "http://fakestarshipurl.com/08", + "http://fakestarshipurl.com/05", + "http://fakestarshipurl.com/16" + ) + ) + ) + + val listOfVehicle = listOf( + Vehicle( + id = "01", + name = "Fake Vehicle Name 01", + model = "Fake Vehicle Model 01" + ), + Vehicle( + id = "02", + name = "Fake Vehicle Name 02", + model = "Fake Vehicle Model 02" + ) + ) + + val listOfStarship = listOf( + Starship( + id = "01", + name = "Fake Starship Name 01", + model = "Fake Starship Model 01" + ), + Starship( + id = "02", + name = "Fake Starship Name 02", + model = "Fake Starship Model 02" + ) + ) + + val listOfCharacter = listOf( + Character( + id = "01", + name = "Fake Starship Name 01", + height = "162", + gender = "female", + birthYear = "BN6943", + vehiclesUrl = listOf( + "http://fakevehicleurl.com/05", + "http://fakevehicleurl.com/07", + "http://fakevehicleurl.com/10" + ), + starshipsUrl = listOf( + "http://fakestarshipurl.com/11", + "http://fakestarshipurl.com/12", + "http://fakestarshipurl.com/30" + ) + ), + Character( + id = "02", + name = "Fake Starship Name 02", + height = "185", + gender = "n/a", + birthYear = "DY1236", + vehiclesUrl = listOf( + "http://fakevehicleurl.com/13", + "http://fakevehicleurl.com/09", + "http://fakevehicleurl.com/10" + ), + starshipsUrl = listOf( + "http://fakestarshipurl.com/08", + "http://fakestarshipurl.com/05", + "http://fakestarshipurl.com/16" + ) + ) + ) +} \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/repository/CharacterRepositoryTest.kt b/app/src/test/java/com/example/hurb_challenge/repository/CharacterRepositoryTest.kt new file mode 100644 index 000000000..689757779 --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/repository/CharacterRepositoryTest.kt @@ -0,0 +1,175 @@ +package com.example.hurb_challenge.repository + +import com.example.hurb_challenge.Mocks +import com.example.hurb_challenge.app.data.ApiService +import com.example.hurb_challenge.app.data.ConnectionFailure +import com.example.hurb_challenge.app.data.Fetching +import com.example.hurb_challenge.app.data.HttpFailure +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.data.repository.CharacterDetailsRepositoryImpl +import com.example.hurb_challenge.app.domain.repository.CharacterDetailsRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import retrofit2.HttpException +import java.net.UnknownHostException + +class CharacterRepositoryTest { + + private lateinit var apiService: ApiService + private lateinit var repository: CharacterDetailsRepository + + @Before + fun setUp() { + apiService = mockk() + repository = CharacterDetailsRepositoryImpl(apiService) + } + + @Test + fun `should first emit Fetching then emit Success of Vehicle`() = runTest { + val expectedResponse = Mocks.listOfVehicleDto + val fakeUrls = listOf( + "http://fakevehicleurl.com/01", + "http://fakevehicleurl.com/02", + "http://fakevehicleurl.com/03" + ).apply { + forEachIndexed { index, url -> + coEvery { apiService.getVehicle(url) } returns expectedResponse[index] + } + } + + val result = repository.getVehicles(fakeUrls) + result.collect { + when (it) { + is Success -> { + Assert.assertEquals(it.value.size, expectedResponse.size) + it.value.forEachIndexed { index, vehicle -> + Assert.assertEquals(vehicle.name, expectedResponse[index].name) + Assert.assertEquals(vehicle.model, expectedResponse[index].model) + } + } + else -> { } + } + } + + val resultTypes = result.toList() + + Assert.assertTrue(resultTypes[0] is Fetching) + Assert.assertTrue(resultTypes[1] is Success) + + coVerify { + fakeUrls.forEach { + apiService.getVehicle(it) + } + } + } + + @Test + fun `should first emit Fetching then emit Success of Starship`() = runTest { + val expectedResponse = Mocks.listOfStarshipsDto + val fakeUrls = listOf( + "http://fakestarshipurl.com/01", + "http://fakestarshipurl.com/02", + "http://fakestarshipurl.com/03" + ).apply { + forEachIndexed { index, url -> + coEvery { apiService.getStarship(url) } returns expectedResponse[index] + } + } + + val result = repository.getStarships(fakeUrls) + result.collect { + when (it) { + is Success -> { + Assert.assertEquals(it.value.size, expectedResponse.size) + it.value.forEachIndexed { index, starship -> + Assert.assertEquals(starship.name, expectedResponse[index].name) + Assert.assertEquals(starship.model, expectedResponse[index].model) + } + } + else -> { } + } + } + + val resultTypes = result.toList() + + Assert.assertTrue(resultTypes[0] is Fetching) + Assert.assertTrue(resultTypes[1] is Success) + + coVerify { + fakeUrls.forEach { + apiService.getStarship(it) + } + } + } + + @Test + fun `should first emit Fetching then emit HttpFailure when call getVehicle`() = runTest { + val expectedResponse = mockk { + every { code() } returns 404 + } + val fakeUrl = "http://fakevehicleurl.com/01" + coEvery { apiService.getVehicle(fakeUrl) } throws expectedResponse + + val result = repository.getVehicles(listOf(fakeUrl)) + val resultType = result.toList() + + Assert.assertEquals(resultType[0], Fetching) + Assert.assertEquals(resultType[1], HttpFailure(404)) + + coVerify { apiService.getVehicle(fakeUrl) } + } + + @Test + fun `should first emit Fetching then emit ConnectionFailure when call getVehicle`() = runTest { + val expectedResponse = mockk() + val fakeUrl = "http://fakevehicleurl.com/01" + coEvery { apiService.getVehicle(fakeUrl) } throws expectedResponse + + val result = repository.getVehicles(listOf(fakeUrl)) + val resultType = result.toList() + + Assert.assertEquals(resultType[0], Fetching) + Assert.assertEquals(resultType[1], ConnectionFailure) + + coVerify { apiService.getVehicle(fakeUrl) } + } + + @Test + fun `should first emit Fetching then emit HttpFailure when call getStarship`() = runTest { + val expectedResponse = mockk { + every { code() } returns 404 + } + val fakeUrl = "http://fakestarshipurl.com/01" + coEvery { apiService.getStarship(fakeUrl) } throws expectedResponse + + val result = repository.getStarships(listOf(fakeUrl)) + val resultType = result.toList() + + Assert.assertEquals(resultType[0], Fetching) + Assert.assertEquals(resultType[1], HttpFailure(404)) + + coVerify { apiService.getStarship(fakeUrl) } + } + + @Test + fun `should first emit Fetching then emit ConnectionFailure when call getStarship`() = runTest { + val expectedResponse = mockk() + val fakeUrl = "http://fakestarshipurl.com/01" + coEvery { apiService.getStarship(fakeUrl) } throws expectedResponse + + val result = repository.getStarships(listOf(fakeUrl)) + val resultType = result.toList() + + Assert.assertEquals(resultType[0], Fetching) + Assert.assertEquals(resultType[1], ConnectionFailure) + + coVerify { apiService.getStarship(fakeUrl) } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/repository/FilmDetailsRepositoryTest.kt b/app/src/test/java/com/example/hurb_challenge/repository/FilmDetailsRepositoryTest.kt new file mode 100644 index 000000000..b5ad00481 --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/repository/FilmDetailsRepositoryTest.kt @@ -0,0 +1,94 @@ +package com.example.hurb_challenge.repository + +import com.example.hurb_challenge.Mocks +import com.example.hurb_challenge.app.data.ApiService +import com.example.hurb_challenge.app.data.Fetching +import com.example.hurb_challenge.app.data.HttpFailure +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.data.repository.FilmDetailsRepositoryImpl +import com.example.hurb_challenge.app.domain.repository.FilmDetailsRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import retrofit2.HttpException + +class FilmDetailsRepositoryTest { + + private lateinit var apiService: ApiService + private lateinit var repository: FilmDetailsRepository + + @Before + fun setUp() { + apiService = mockk() + repository = FilmDetailsRepositoryImpl(apiService) + } + + @Test + fun `should first emit Fetching then emit a Success`() = runTest { + val expectedResponse = Mocks.listOfCharactersDto + val fakeUrls = listOf( + "http://fakecharacterurl.com/01", + "http://fakecharacterurl.com/02", + "http://fakecharacterurl.com/03" + ).apply { + forEachIndexed { index, url -> + coEvery { apiService.getCharacter(url) } returns expectedResponse[index] + } + } + + val result = repository.getCharactersByUrls(fakeUrls) + + result.collect { + when (it) { + is Success -> { + it.value.forEachIndexed { index, character -> + Assert.assertEquals(character.name, expectedResponse[index].name) + Assert.assertEquals(character.height, expectedResponse[index].height) + Assert.assertEquals(character.birthYear, expectedResponse[index].birthYear) + + Assert.assertNotEquals(character.starshipsUrl, expectedResponse[index].starshipsUrl) + Assert.assertNotEquals(character.vehiclesUrl, expectedResponse[index].vehiclesUrl) + } + } + else -> {} + } + } + + val resultTypes = result.toList() + + Assert.assertTrue(resultTypes[0] is Fetching) + Assert.assertTrue(resultTypes[1] is Success) + + coVerify { + fakeUrls.forEach { + apiService.getCharacter(it) + } + } + } + + @Test + fun `should first emit Fetching then emit a HttpFailure`() = runTest { + val expectedResponse = mockk { + every { code() } returns 404 + } + val fakeUrl = "http://fakecharacterurl.com/01" + coEvery { apiService.getCharacter(fakeUrl) } throws expectedResponse + + val result = repository.getCharactersByUrls(listOf(fakeUrl)) + + val resultTypes = result.toList() + + Assert.assertTrue(resultTypes[0] is Fetching) + Assert.assertEquals(resultTypes[1], HttpFailure(404)) + + coVerify { + apiService.getCharacter(fakeUrl) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/repository/FilmsRepositoryTest.kt b/app/src/test/java/com/example/hurb_challenge/repository/FilmsRepositoryTest.kt new file mode 100644 index 000000000..476d8f6ec --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/repository/FilmsRepositoryTest.kt @@ -0,0 +1,57 @@ +package com.example.hurb_challenge.repository + +import com.example.hurb_challenge.Mocks +import com.example.hurb_challenge.app.data.ApiService +import com.example.hurb_challenge.app.data.repository.FilmsRepositoryImpl +import com.example.hurb_challenge.app.domain.repository.FilmsRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import retrofit2.HttpException + +class FilmsRepositoryTest { + + private lateinit var repository: FilmsRepository + private lateinit var apiService: ApiService + + @Before + fun setUp() { + apiService = mockk() + repository = FilmsRepositoryImpl(apiService) + } + + @Test + fun `should return a flow of api response film dto when service call returns success`() = runTest { + val expectedResponse = Mocks.apiResponseFilmDto + coEvery { apiService.getFilms(1) } returns expectedResponse + + val result = repository.getFilms(1) + result.collect { + assertEquals(it.count, expectedResponse.count) + assertEquals(it.next, expectedResponse.next) + assertEquals(it.previous, expectedResponse.previous) + assertEquals(it.results, expectedResponse.results) + assertEquals(it, expectedResponse) + } + + coVerify{ apiService.getFilms(1) } + } + + @Test + fun `should return a flow with exception when service call returns an error`() = runTest { + val expectedResponse = mockk() + coEvery { apiService.getFilms(1) } throws expectedResponse + + val result = repository.getFilms(1) + + result.collect { + assertEquals(it, expectedResponse) + } + + coVerify { apiService.getFilms(1) } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/usecase/GetCharacterStarshipsUseCaseTest.kt b/app/src/test/java/com/example/hurb_challenge/usecase/GetCharacterStarshipsUseCaseTest.kt new file mode 100644 index 000000000..f4e35ea58 --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/usecase/GetCharacterStarshipsUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.example.hurb_challenge.usecase + +import com.example.hurb_challenge.Mocks +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.domain.repository.CharacterDetailsRepository +import com.example.hurb_challenge.app.domain.usecase.GetCharacterStarshipsUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class GetCharacterStarshipsUseCaseTest { + + private lateinit var repository: CharacterDetailsRepository + private lateinit var useCase: GetCharacterStarshipsUseCase + + @Before + fun setUp() { + repository = mockk() + useCase = GetCharacterStarshipsUseCase(repository) + } + + @Test + fun `should call getStarships`() = runTest { + val fakeUrls = listOf( + "http://fakecharacterurl.com/01", + "http://fakecharacterurl.com/02", + "http://fakecharacterurl.com/03" + ) + coEvery { repository.getStarships(fakeUrls) } returns flowOf(Success(Mocks.listOfStarship)) + useCase.call(fakeUrls) + coVerify { + repository.getStarships(fakeUrls) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/usecase/GetCharactersInFilmUseCaseTest.kt b/app/src/test/java/com/example/hurb_challenge/usecase/GetCharactersInFilmUseCaseTest.kt new file mode 100644 index 000000000..dfd48b606 --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/usecase/GetCharactersInFilmUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.example.hurb_challenge.usecase + +import com.example.hurb_challenge.Mocks +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.domain.repository.FilmDetailsRepository +import com.example.hurb_challenge.app.domain.usecase.GetCharactersInFilmUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class GetCharactersInFilmUseCaseTest { + + private lateinit var repository: FilmDetailsRepository + private lateinit var useCase: GetCharactersInFilmUseCase + + @Before + fun setUp() { + repository = mockk() + useCase = GetCharactersInFilmUseCase(repository) + } + + @Test + fun `should call getCharactersByUrls`() = runTest { + val fakeUrls = listOf( + "http://fakecharacterurl.com/01", + "http://fakecharacterurl.com/02", + "http://fakecharacterurl.com/03" + ) + coEvery { repository.getCharactersByUrls(fakeUrls) } returns flowOf(Success(Mocks.listOfCharacter)) + useCase.call(fakeUrls) + coVerify { + repository.getCharactersByUrls(fakeUrls) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/usecase/GetCharactersVehicleUseCaseTest.kt b/app/src/test/java/com/example/hurb_challenge/usecase/GetCharactersVehicleUseCaseTest.kt new file mode 100644 index 000000000..c129f97a2 --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/usecase/GetCharactersVehicleUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.example.hurb_challenge.usecase + +import com.example.hurb_challenge.Mocks +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.domain.repository.CharacterDetailsRepository +import com.example.hurb_challenge.app.domain.usecase.GetCharacterVehiclesUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class GetCharactersVehicleUseCaseTest { + + private lateinit var repository: CharacterDetailsRepository + private lateinit var useCase: GetCharacterVehiclesUseCase + + @Before + fun setUp() { + repository = mockk() + useCase = GetCharacterVehiclesUseCase(repository) + } + + @Test + fun `should call getVehicles`() = runTest { + val fakeUrls = listOf( + "http://fakecharacterurl.com/01", + "http://fakecharacterurl.com/02", + "http://fakecharacterurl.com/03" + ) + coEvery { repository.getVehicles(fakeUrls) } returns flowOf(Success(Mocks.listOfVehicle)) + useCase.call(fakeUrls) + coVerify { + repository.getVehicles(fakeUrls) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/viewmodel/CharacterDetailsViewModelTest.kt b/app/src/test/java/com/example/hurb_challenge/viewmodel/CharacterDetailsViewModelTest.kt new file mode 100644 index 000000000..7ed86b9d4 --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/viewmodel/CharacterDetailsViewModelTest.kt @@ -0,0 +1,111 @@ +package com.example.hurb_challenge + +import com.example.hurb_challenge.app.data.Fetching +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.data.UnknownFailure +import com.example.hurb_challenge.app.domain.toCardItem +import com.example.hurb_challenge.app.domain.usecase.GetCharacterStarshipsUseCase +import com.example.hurb_challenge.app.domain.usecase.GetCharacterVehiclesUseCase +import com.example.hurb_challenge.app.presentation.characterdetails.CharacterDetailsViewModel +import com.example.hurb_challenge.app.presentation.common.Empty +import com.example.hurb_challenge.app.presentation.common.Error +import com.example.hurb_challenge.app.presentation.common.Loaded +import com.example.hurb_challenge.app.presentation.common.Loading +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CharacterDetailsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var getCharacterVehiclesUseCase: GetCharacterVehiclesUseCase + private lateinit var getCharacterStarshipsUseCase: GetCharacterStarshipsUseCase + private lateinit var viewModel: CharacterDetailsViewModel + + @Before + fun setUp() { + getCharacterVehiclesUseCase = mockk() + getCharacterStarshipsUseCase = mockk() + viewModel = CharacterDetailsViewModel(getCharacterVehiclesUseCase, getCharacterStarshipsUseCase, testDispatcher) + } + + @Test + fun `vehicles state flow should be Empty`() = runTest { + Assert.assertEquals(viewModel.characterVehiclesStateFlow.value, Empty) + } + + @Test + fun `vehicles state flow should be Loading`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + coEvery { getCharacterVehiclesUseCase.call(fakeUrls) } returns flowOf(Fetching) + + viewModel.loadCharacterVehicles(fakeUrls) + + Assert.assertEquals(viewModel.characterVehiclesStateFlow.value, Loading) + } + + @Test + fun `vehicles state flow should be Loaded`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + val vehicles = Mocks.listOfVehicle + coEvery { getCharacterVehiclesUseCase.call(fakeUrls) } returns flowOf(Success(vehicles)) + + viewModel.loadCharacterVehicles(fakeUrls) + val cardItems = vehicles.map { it.toCardItem() } + Assert.assertEquals(viewModel.characterVehiclesStateFlow.value, Loaded(cardItems)) + } + + @Test + fun `vehicles state flow should be Error`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + coEvery { getCharacterVehiclesUseCase.call(fakeUrls) } returns flowOf(UnknownFailure) + + viewModel.loadCharacterVehicles(fakeUrls) + + Assert.assertEquals(viewModel.characterVehiclesStateFlow.value, Error("Unknown error")) + } + + @Test + fun `starships state flow should be Empty`() = runTest { + Assert.assertEquals(viewModel.characterStarshipsStateFlow.value, Empty) + } + + @Test + fun `starships state flow should be Loading`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + coEvery { getCharacterStarshipsUseCase.call(fakeUrls) } returns flowOf(Fetching) + + viewModel.loadCharacterStarships(fakeUrls) + + Assert.assertEquals(viewModel.characterStarshipsStateFlow.value, Loading) + } + + @Test + fun `starships state flow should be Loaded`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + val starships = Mocks.listOfStarship + coEvery { getCharacterStarshipsUseCase.call(fakeUrls) } returns flowOf(Success(starships)) + + viewModel.loadCharacterStarships(fakeUrls) + + val cardItems = starships.map { it.toCardItem() } + Assert.assertEquals(viewModel.characterStarshipsStateFlow.value, Loaded(cardItems)) + } + + @Test + fun `starships state flow should be Error`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + coEvery { getCharacterStarshipsUseCase.call(fakeUrls) } returns flowOf(UnknownFailure) + + viewModel.loadCharacterStarships(fakeUrls) + + Assert.assertEquals(viewModel.characterStarshipsStateFlow.value, Error("Unknown error")) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/hurb_challenge/viewmodel/FilmDetailsViewModelTest.kt b/app/src/test/java/com/example/hurb_challenge/viewmodel/FilmDetailsViewModelTest.kt new file mode 100644 index 000000000..be37cc994 --- /dev/null +++ b/app/src/test/java/com/example/hurb_challenge/viewmodel/FilmDetailsViewModelTest.kt @@ -0,0 +1,72 @@ +package com.example.hurb_challenge + +import com.example.hurb_challenge.app.data.Fetching +import com.example.hurb_challenge.app.data.Success +import com.example.hurb_challenge.app.data.UnknownFailure +import com.example.hurb_challenge.app.domain.toCardItem +import com.example.hurb_challenge.app.domain.usecase.GetCharactersInFilmUseCase +import com.example.hurb_challenge.app.presentation.common.Empty +import com.example.hurb_challenge.app.presentation.common.Error +import com.example.hurb_challenge.app.presentation.common.Loaded +import com.example.hurb_challenge.app.presentation.common.Loading +import com.example.hurb_challenge.app.presentation.filmdetails.FilmDetailsViewModel +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FilmDetailsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var useCase: GetCharactersInFilmUseCase + private lateinit var viewModel: FilmDetailsViewModel + + @Before + fun setUp() { + useCase = mockk() + viewModel = FilmDetailsViewModel(useCase, testDispatcher) + } + + @Test + fun `characters state flow should be Empty`() = runTest { + Assert.assertEquals(viewModel.charactersStateFlow.value, Empty) + } + + @Test + fun `vehicles state flow should be Loading`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + coEvery { useCase.call(fakeUrls) } returns flow { emit(Fetching) } + + viewModel.loadCharactersByUrl(fakeUrls) + + Assert.assertEquals(viewModel.charactersStateFlow.value, Loading) + } + + @Test + fun `vehicles state flow should be Loaded`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + val characters = Mocks.listOfCharacter + coEvery { useCase.call(fakeUrls) } returns flow { emit(Success(characters)) } + + viewModel.loadCharactersByUrl(fakeUrls) + + val cardItems = characters.map { it.toCardItem() } + Assert.assertEquals(viewModel.charactersStateFlow.value, Loaded(cardItems)) + } + + @Test + fun `vehicles state flow should be Error`() = runTest { + val fakeUrls = listOf("http://fakeurl1.com", "http://fakeurl2.com") + coEvery { useCase.call(fakeUrls) } returns flow { emit(UnknownFailure) } + + viewModel.loadCharactersByUrl(fakeUrls) + + Assert.assertEquals(viewModel.charactersStateFlow.value, Error("Unknown error")) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..a1b880575 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,18 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.jetbrainsKotlinAndroid) apply false + alias(libs.plugins.hilt.kapt) apply false + alias(libs.plugins.kotlin.parcelize) apply false +} + +buildscript { + repositories { + // other repositories... + mavenCentral() + } + dependencies { + // other plugins... + classpath(libs.hilt.android.gradle.plugin) + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..20e2a0152 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..af97fbfe3 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,75 @@ +[versions] +compileSdk = "34" +versionCode = "1" +targetSdk = "34" +minSdk = "23" + +agp = "8.3.2" +coilCompose = "2.6.0" +converterGson = "2.11.0" +hiltAndroidCompiler = "2.49" +hiltAndroidTesting = "2.49" +hiltNavigationCompose = "1.2.0" +kotlin = "1.9.0" +kotlinxCoroutinesTest = "1.7.3" +coreKtx = "1.13.1" +junit = "4.13.2" +junitVersion = "1.2.1" +espressoCore = "3.5.1" +kotlinxCoroutinesAndroid = "1.7.1" +kotlinxSerializationJson = "1.6.3" +lifecycleRuntimeKtx = "2.7.0" +activityCompose = "1.9.0" +composeBom = "2023.08.00" +paging = "3.3.0" +splashscreen = "1.0.0" +loggingInterceptor = "4.12.0" +mockk = "1.13.10" +navigationComposeVersion = "2.8.0-alpha08" +okhttp = "4.12.0" +retrofit = "2.11.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidCompiler" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" } +hilt-android-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hiltAndroidCompiler" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hiltAndroidTesting" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltAndroidTesting" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +paging = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } +splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +# mockk = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } +material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +hilt-kapt = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroidCompiler" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e708b1c02 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..430b4c6b1 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jul 03 10:22:08 BRT 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..4f906e0c8 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..107acd32c --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pull-request.txt b/pull-request.txt index 4eae37418..b16cfcbc0 100644 --- a/pull-request.txt +++ b/pull-request.txt @@ -1,3 +1,3 @@ -Your name: ___ -Your Github homepage: ___ -Original challenge URL: http://github.com/hurbcom/challenge-___ +Your name: Mauricio Sousa Silva +Your Github homepage: https://github.com/mauricio-silva +Original challenge URL: https://github.com/mauricio-silva/challenge-alpha diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..733790e17 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "challenge-alpha" +include(":app") + \ No newline at end of file