diff --git a/README.md b/README.md
index f59dee2..4d28b45 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,19 @@
# Dogga
-Simple Android Native Application consuming the https://dog.ceo/dog-api/
+
+![Dogga App icon](app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp)
+
+Simple Android Native Application consuming the [Dog API](https://dog.ceo/dog-api/)
+
+This app is made just for portfolio and study purposes and it is in constant evolution.
+
+
+It is made using:
+
+- MVVM
+- Jetpack Compose
+- Retrofit
+- JUnit4
+- Mockk
+- Kotlin DSL (kts script)
+- Koin
+
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 28dfeed..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,66 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'org.jetbrains.kotlin.android'
-}
-
-android {
- namespace 'dev.lhalegria.dogga'
- compileSdk 33
-
- defaultConfig {
- applicationId "dev.lhalegria.dogga"
- minSdk 30
- targetSdk 33
- versionCode 1
- versionName "1.0"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables {
- useSupportLibrary true
- }
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = '1.8'
- }
- buildFeatures {
- compose true
- }
- composeOptions {
- kotlinCompilerExtensionVersion '1.3.2'
- }
- packagingOptions {
- resources {
- excludes += '/META-INF/{AL2.0,LGPL2.1}'
- }
- }
-}
-
-dependencies {
- implementation 'androidx.core:core-ktx:1.10.1'
- implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
- implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
- implementation 'androidx.activity:activity-compose:1.7.2'
- implementation platform('androidx.compose:compose-bom:2022.10.00')
- implementation 'androidx.compose.ui:ui'
- implementation 'androidx.compose.ui:ui-graphics'
- implementation 'androidx.compose.ui:ui-tooling-preview'
- implementation 'androidx.compose.material3:material3'
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
- androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
- androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
- debugImplementation 'androidx.compose.ui:ui-tooling'
- debugImplementation 'androidx.compose.ui:ui-test-manifest'
-}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..a07ddc4
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,105 @@
+import BuildSetup.APPLICATION_ID
+import BuildSetup.BUILD_TOOLS_VERSION
+import BuildSetup.COMPILE_SDK_VERSION
+import BuildSetup.COMPOSE_KOTLIN_COMPILER
+import BuildSetup.MIN_SDK_VERSION
+import BuildSetup.NAMESPACE
+import BuildSetup.TARGET_SDK_VERSION
+import BuildSetup.TEST_INSTRUMENTATION_RUNNER
+import BuildSetup.VERSION_CODE
+import BuildSetup.VERSION_NAME
+
+plugins {
+ id(Plugins.ANDROID_APPLICATION)
+ kotlin(Plugins.KOTLIN_ANDROID)
+}
+
+@Suppress("UnstableApiUsage")
+android {
+ namespace = NAMESPACE
+ compileSdk = COMPILE_SDK_VERSION
+ buildToolsVersion = BUILD_TOOLS_VERSION
+
+ defaultConfig {
+ applicationId = APPLICATION_ID
+
+ minSdk = MIN_SDK_VERSION
+ targetSdk = TARGET_SDK_VERSION
+ versionCode = VERSION_CODE
+ versionName = VERSION_NAME
+
+ vectorDrawables.useSupportLibrary = true
+ testInstrumentationRunner = TEST_INSTRUMENTATION_RUNNER
+ }
+
+ buildTypes {
+ debug {
+ applicationIdSuffix = BuildTypeDebug.applicationIdSuffix
+ versionNameSuffix = BuildTypeDebug.versionNameSuffix
+ }
+
+ release {
+ isMinifyEnabled = BuildTypeRelease.isMinifyEnabled
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8.toString()
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = COMPOSE_KOTLIN_COMPILER
+ }
+
+ packaging {
+ resources.excludes.addAll(
+ listOf("/META-INF/{AL2.0,LGPL2.1}")
+ )
+ }
+}
+
+dependencies {
+ implementation(AndroidX.CORE)
+ implementation(AndroidX.LIFECYCLE)
+ implementation(AndroidX.Compose.ACTIVITY)
+ implementation(platform(AndroidX.Compose.BOM))
+ implementation(AndroidX.Compose.LIFECYCLE)
+ implementation(AndroidX.Compose.UI)
+ implementation(AndroidX.Compose.UI_GRAPHICS)
+ implementation(AndroidX.Compose.UI_TOOLING)
+ implementation(AndroidX.Compose.MATERIAL_3)
+ implementation(AndroidX.Compose.NAVIGATION)
+
+ implementation(platform(Other.KOTLIN_BOM))
+ implementation(Other.COIL)
+
+ implementation(Koin.KOIN)
+ implementation(Koin.KOIN_COMPOSE)
+
+ implementation(Square.RETROFIT)
+ implementation(Square.OKHTTP)
+ implementation(Square.OKHTTP_LOG_INTERCEPTOR)
+ implementation(Square.GSON_CONVERTER)
+
+ testImplementation(Test.JUNIT)
+ testImplementation(Test.MOCKK)
+ testImplementation(Test.CORE_TESTING)
+ testImplementation(Test.COROUTINES)
+ androidTestImplementation(Test.ANDROIDX_JUNIT)
+ androidTestImplementation(Test.ESPRESSO)
+ androidTestImplementation(platform(AndroidX.Compose.BOM))
+ androidTestImplementation(Test.COMPOSE_UI_JUNIT4)
+
+ debugImplementation(AndroidX.Compose.UI_TOOLING)
+ debugImplementation(Test.COMPOSE_TEST_MANIFEST)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb43..2f9dc5a 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
+# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@@ -18,4 +18,4 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
diff --git a/app/src/androidTest/java/dev/lhalegria/dogga/ExampleInstrumentedTest.kt b/app/src/androidTest/kotlin/dev/lhalegria/dogga/ExampleInstrumentedTest.kt
similarity index 100%
rename from app/src/androidTest/java/dev/lhalegria/dogga/ExampleInstrumentedTest.kt
rename to app/src/androidTest/kotlin/dev/lhalegria/dogga/ExampleInstrumentedTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 48f8250..9e0184d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,10 @@
+
+
+
-
-
diff --git a/app/src/main/ic_launcher-playstore.webp b/app/src/main/ic_launcher-playstore.webp
new file mode 100644
index 0000000..3b43a8e
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.webp differ
diff --git a/app/src/main/java/dev/lhalegria/dogga/MainActivity.kt b/app/src/main/java/dev/lhalegria/dogga/MainActivity.kt
deleted file mode 100644
index 6e9ad86..0000000
--- a/app/src/main/java/dev/lhalegria/dogga/MainActivity.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package dev.lhalegria.dogga
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import dev.lhalegria.dogga.ui.theme.DoggaTheme
-
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContent {
- DoggaTheme {
- // A surface container using the 'background' color from the theme
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
- ) {
- Greeting("Android")
- }
- }
- }
- }
-}
-
-@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
- Text(
- text = "Hello $name!",
- modifier = modifier
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun GreetingPreview() {
- DoggaTheme {
- Greeting("Android")
- }
-}
diff --git a/app/src/main/java/dev/lhalegria/dogga/ui/theme/Color.kt b/app/src/main/java/dev/lhalegria/dogga/ui/theme/Color.kt
deleted file mode 100644
index 38b262c..0000000
--- a/app/src/main/java/dev/lhalegria/dogga/ui/theme/Color.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package dev.lhalegria.dogga.ui.theme
-
-import androidx.compose.ui.graphics.Color
-
-val Purple80 = Color(0xFFD0BCFF)
-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/dev/lhalegria/dogga/ui/theme/Type.kt b/app/src/main/java/dev/lhalegria/dogga/ui/theme/Type.kt
deleted file mode 100644
index 435ccb9..0000000
--- a/app/src/main/java/dev/lhalegria/dogga/ui/theme/Type.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package dev.lhalegria.dogga.ui.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/kotlin/dev/lhalegria/dogga/DoggaApp.kt b/app/src/main/kotlin/dev/lhalegria/dogga/DoggaApp.kt
new file mode 100644
index 0000000..350f766
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/DoggaApp.kt
@@ -0,0 +1,25 @@
+package dev.lhalegria.dogga
+
+import android.app.Application
+import android.content.res.Resources
+import dev.lhalegria.dogga.datasource.di.dataSourceModule
+import dev.lhalegria.dogga.di.mainModule
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
+
+class DoggaApp : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ appResources = this.resources
+ startKoin {
+ androidContext(this@DoggaApp)
+ modules(listOf(dataSourceModule, mainModule))
+ }
+ }
+
+ companion object {
+ lateinit var appResources: Resources
+ private set
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/datasource/di/DataSourceModule.kt b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/di/DataSourceModule.kt
new file mode 100644
index 0000000..474770d
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/di/DataSourceModule.kt
@@ -0,0 +1,11 @@
+package dev.lhalegria.dogga.datasource.di
+
+import dev.lhalegria.dogga.datasource.network.retrofitApi
+import dev.lhalegria.dogga.datasource.service.BreedService
+import org.koin.dsl.module
+
+val dataSourceModule = module {
+
+ factory { retrofitApi }
+ factory { retrofitApi.create(BreedService::class.java) }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/datasource/network/DoggaNetworkApi.kt b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/network/DoggaNetworkApi.kt
new file mode 100644
index 0000000..9825598
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/network/DoggaNetworkApi.kt
@@ -0,0 +1,28 @@
+package dev.lhalegria.dogga.datasource.network
+
+import com.google.gson.FieldNamingPolicy
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.LongSerializationPolicy
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+private const val BASE_URL = "https://dog.ceo/api/"
+
+private val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+}
+
+private val gson: Gson = GsonBuilder()
+ .setLongSerializationPolicy(LongSerializationPolicy.DEFAULT)
+ .setFieldNamingStrategy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create()
+
+val retrofitApi: Retrofit
+ get() = Retrofit.Builder()
+ .baseUrl(BASE_URL)
+ .client(OkHttpClient.Builder().addInterceptor(httpLoggingInterceptor).build())
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build()
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/datasource/response/BreedImageResponse.kt b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/response/BreedImageResponse.kt
new file mode 100644
index 0000000..b68e1f0
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/response/BreedImageResponse.kt
@@ -0,0 +1,8 @@
+package dev.lhalegria.dogga.datasource.response
+
+import com.google.gson.annotations.SerializedName
+
+data class BreedImageResponse(
+ @SerializedName("message") val imageUrl: String,
+ @SerializedName("status") val status: String
+)
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/datasource/response/BreedResponse.kt b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/response/BreedResponse.kt
new file mode 100644
index 0000000..587aed2
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/response/BreedResponse.kt
@@ -0,0 +1,8 @@
+package dev.lhalegria.dogga.datasource.response
+
+import com.google.gson.annotations.SerializedName
+
+data class BreedResponse(
+ @SerializedName("message") val breedsList: List,
+ @SerializedName("status") val status: String
+)
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/datasource/service/BreedService.kt b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/service/BreedService.kt
new file mode 100644
index 0000000..fa8169c
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/datasource/service/BreedService.kt
@@ -0,0 +1,23 @@
+package dev.lhalegria.dogga.datasource.service
+
+import dev.lhalegria.dogga.datasource.response.BreedImageResponse
+import dev.lhalegria.dogga.datasource.response.BreedResponse
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Path
+
+interface BreedService {
+
+ @GET("breeds/list")
+ suspend fun getBreeds(): Response
+
+ @GET("breed/{breed}/list")
+ suspend fun getSubBreedsFromBreed(
+ @Path("breed") breed: String
+ ): Response
+
+ @GET("breed/{breed}/images/random")
+ suspend fun getBreedImage(
+ @Path("breed") breed: String
+ ): Response
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/di/MainModule.kt b/app/src/main/kotlin/dev/lhalegria/dogga/di/MainModule.kt
new file mode 100644
index 0000000..e3a0c92
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/di/MainModule.kt
@@ -0,0 +1,24 @@
+package dev.lhalegria.dogga.di
+
+import dev.lhalegria.dogga.model.mapper.BreedMapper
+import dev.lhalegria.dogga.model.mapper.IBreedMapper
+import dev.lhalegria.dogga.repository.BreedRepository
+import dev.lhalegria.dogga.repository.IBreedRepository
+import dev.lhalegria.dogga.viewmodel.BreedViewModel
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+
+val mainModule = module {
+
+ single { BreedMapper() }
+
+ factory {
+ BreedRepository(service = get(), mapper = get())
+ }
+
+ viewModel {
+ BreedViewModel(
+ repository = get()
+ )
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/exception/ResponseErrorException.kt b/app/src/main/kotlin/dev/lhalegria/dogga/exception/ResponseErrorException.kt
new file mode 100644
index 0000000..f0cff45
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/exception/ResponseErrorException.kt
@@ -0,0 +1,12 @@
+package dev.lhalegria.dogga.exception
+
+import dev.lhalegria.dogga.DoggaApp
+import dev.lhalegria.dogga.R
+
+class ResponseErrorException(
+ private val code: Int
+): Exception() {
+
+ override val message: String
+ get() = String.format(DoggaApp.appResources.getString(R.string.response_error), code)
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/exception/ResponseNoBodyException.kt b/app/src/main/kotlin/dev/lhalegria/dogga/exception/ResponseNoBodyException.kt
new file mode 100644
index 0000000..661f7d6
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/exception/ResponseNoBodyException.kt
@@ -0,0 +1,10 @@
+package dev.lhalegria.dogga.exception
+
+import dev.lhalegria.dogga.DoggaApp
+import dev.lhalegria.dogga.R
+
+class ResponseNoBodyException : Exception() {
+
+ override val message: String
+ get() = DoggaApp.appResources.getString(R.string.no_body_exception)
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/model/BreedModel.kt b/app/src/main/kotlin/dev/lhalegria/dogga/model/BreedModel.kt
new file mode 100644
index 0000000..8774154
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/model/BreedModel.kt
@@ -0,0 +1,6 @@
+package dev.lhalegria.dogga.model
+
+data class BreedModel(
+ val name: String,
+ val photo: String = ""
+)
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/model/mapper/BreedMapper.kt b/app/src/main/kotlin/dev/lhalegria/dogga/model/mapper/BreedMapper.kt
new file mode 100644
index 0000000..2e0fe4a
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/model/mapper/BreedMapper.kt
@@ -0,0 +1,10 @@
+package dev.lhalegria.dogga.model.mapper
+
+import dev.lhalegria.dogga.datasource.response.BreedResponse
+import dev.lhalegria.dogga.model.BreedModel
+
+class BreedMapper : IBreedMapper {
+
+ override fun breedsResponseToModel(response: BreedResponse) =
+ response.breedsList.map { BreedModel(it) }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/model/mapper/IBreedMapper.kt b/app/src/main/kotlin/dev/lhalegria/dogga/model/mapper/IBreedMapper.kt
new file mode 100644
index 0000000..9fcaac1
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/model/mapper/IBreedMapper.kt
@@ -0,0 +1,9 @@
+package dev.lhalegria.dogga.model.mapper
+
+import dev.lhalegria.dogga.datasource.response.BreedResponse
+import dev.lhalegria.dogga.model.BreedModel
+
+interface IBreedMapper {
+
+ fun breedsResponseToModel(response: BreedResponse): List
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/repository/BreedRepository.kt b/app/src/main/kotlin/dev/lhalegria/dogga/repository/BreedRepository.kt
new file mode 100644
index 0000000..35100cf
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/repository/BreedRepository.kt
@@ -0,0 +1,39 @@
+package dev.lhalegria.dogga.repository
+
+import dev.lhalegria.dogga.datasource.service.BreedService
+import dev.lhalegria.dogga.exception.ResponseErrorException
+import dev.lhalegria.dogga.exception.ResponseNoBodyException
+import dev.lhalegria.dogga.model.BreedModel
+import dev.lhalegria.dogga.model.mapper.IBreedMapper
+import retrofit2.Response
+
+class BreedRepository(
+ private val service: BreedService,
+ private val mapper: IBreedMapper
+) : IBreedRepository {
+
+ override suspend fun getBreeds(): Result> =
+ getResultFromResponse(service.getBreeds())
+ .map { mapper.breedsResponseToModel(it) }
+
+ override suspend fun getSubBreed(masterBreed: String): Result> =
+ getResultFromResponse(service.getSubBreedsFromBreed(masterBreed))
+ .map { mapper.breedsResponseToModel(it) }
+
+ override suspend fun getBreedImage(breed: String): Result =
+ getResultFromResponse(service.getBreedImage(breed))
+ .map { it.imageUrl }
+
+ private fun getResultFromResponse(response: Response): Result =
+ try {
+ if (response.isSuccessful) {
+ response.body()?.let {
+ Result.success(it)
+ } ?: throw ResponseNoBodyException()
+ } else {
+ Result.failure(ResponseErrorException(response.code()))
+ }
+ } catch (ex: Exception) {
+ Result.failure(ex)
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/repository/IBreedRepository.kt b/app/src/main/kotlin/dev/lhalegria/dogga/repository/IBreedRepository.kt
new file mode 100644
index 0000000..95e3990
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/repository/IBreedRepository.kt
@@ -0,0 +1,12 @@
+package dev.lhalegria.dogga.repository
+
+import dev.lhalegria.dogga.model.BreedModel
+
+interface IBreedRepository {
+
+ suspend fun getBreeds(): Result>
+
+ suspend fun getSubBreed(masterBreed: String): Result>
+
+ suspend fun getBreedImage(breed: String): Result
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/MainActivity.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/MainActivity.kt
new file mode 100644
index 0000000..b10d6f8
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/MainActivity.kt
@@ -0,0 +1,64 @@
+package dev.lhalegria.dogga.view
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dev.lhalegria.dogga.model.BreedModel
+import dev.lhalegria.dogga.view.composable.AppNavigation
+import dev.lhalegria.dogga.view.composable.EmptyDataBox
+import dev.lhalegria.dogga.view.composable.ErrorBox
+import dev.lhalegria.dogga.view.composable.LoadingBox
+import dev.lhalegria.dogga.view.ui.theme.DoggaTheme
+import dev.lhalegria.dogga.viewmodel.BreedViewModel
+import dev.lhalegria.dogga.viewmodel.RequestState
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class MainActivity : ComponentActivity() {
+
+ private val viewModel: BreedViewModel by viewModel()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ viewModel.getBreeds()
+
+ setContent {
+ DoggaTheme {
+ var retry by remember { mutableStateOf(false) }
+ val retryAction = { retry = true }
+
+ if (retry) {
+ viewModel.getBreeds()
+ }
+
+ val breedsState by viewModel.breedStateFlow.collectAsStateWithLifecycle()
+ when (breedsState) {
+ RequestState.Loading -> LoadingBox()
+ is RequestState.Error -> ErrorContainer(state = breedsState) { retry = true }
+ is RequestState.Success -> SuccessContainer(breedsState, retryAction)
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun ErrorContainer(state: RequestState>?, actionOnError: () -> Unit) {
+ val errorMessage = (state as? RequestState.Error)?.t?.message.orEmpty()
+ ErrorBox(errorCause = errorMessage, action = actionOnError)
+ }
+
+ @Composable
+ fun SuccessContainer(state: RequestState>?, actionOnEmpty: () -> Unit) {
+ val breeds = (state as? RequestState.Success>)?.data
+ if (breeds?.isNotEmpty() == false) {
+ AppNavigation(breeds = breeds)
+ } else {
+ EmptyDataBox(actionOnEmpty)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedDetail.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedDetail.kt
new file mode 100644
index 0000000..0ca031a
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedDetail.kt
@@ -0,0 +1,104 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.layout.wrapContentHeight
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavHostController
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import dev.lhalegria.dogga.R
+import dev.lhalegria.dogga.viewmodel.BreedViewModel
+import dev.lhalegria.dogga.viewmodel.RequestState
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BreedDetail(
+ breed: String,
+ navController: NavHostController?,
+ viewModel: BreedViewModel = koinViewModel()
+) {
+ viewModel.getBreedImage(breed)
+
+ val imageState by viewModel.breedImageStateFlow.collectAsStateWithLifecycle()
+
+ Scaffold(topBar = {
+ Toolbar(
+ title = stringResource(id = R.string.breed_detail),
+ icon = Icons.Default.ArrowBack
+ ) {
+ navController?.navigateUp()
+ }
+ }) { padding ->
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Top
+ ) {
+ when (imageState) {
+ RequestState.Loading -> BreedImage(name = breed)
+ is RequestState.Error -> BreedImage(name = breed)
+ is RequestState.Success ->
+ BreedImage(name = breed, url = (imageState as? RequestState.Success)?.data.orEmpty())
+ }
+
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(top = 10.dp),
+ text = breed.replaceFirstChar { it.uppercaseChar() },
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineMedium
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun BreedImage(name: String, url: String = "") {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(url)
+ .crossfade(true)
+ .build(),
+ placeholder = painterResource(id = R.drawable.ic_placeholder),
+ error = painterResource(id = R.drawable.ic_placeholder),
+ contentDescription = String.format(stringResource(id = R.string.breed_image_content_description), name),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .fillMaxWidth()
+ .size(300.dp)
+ .padding(0.dp)
+ .background(color = MaterialTheme.colorScheme.onPrimary)
+ )
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedList.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedList.kt
new file mode 100644
index 0000000..c9ae246
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedList.kt
@@ -0,0 +1,49 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.navigation.NavHostController
+import dev.lhalegria.dogga.R
+import dev.lhalegria.dogga.model.BreedModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BreedList(
+ dogs: List,
+ navController: NavHostController?
+) {
+ Scaffold(topBar = {
+ Toolbar(
+ title = stringResource(id = R.string.breed_list),
+ icon = ImageVector.vectorResource(id = R.drawable.ic_toolbar_icon)
+ ) {}
+ }) { padding ->
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ ) {
+ LazyColumn {
+ items(dogs) {
+ BreedListItem(dog = it) {
+ navController?.navigate("$BREED_DETAIL_ROUTE/${it.name}")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedListItem.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedListItem.kt
new file mode 100644
index 0000000..5d051a9
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedListItem.kt
@@ -0,0 +1,36 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.lhalegria.dogga.model.BreedModel
+
+@Composable
+fun BreedListItem(dog: BreedModel, clickAction: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .padding(top = 8.dp, bottom = 4.dp, start = 16.dp, end = 16.dp)
+ .fillMaxWidth()
+ .wrapContentHeight(align = Alignment.Top)
+ .clickable { clickAction.invoke() },
+ elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start
+ ) {
+ BreedListItemIcon()
+ BreedListItemText(dog.name, Alignment.Start)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedListItemContent.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedListItemContent.kt
new file mode 100644
index 0000000..907f193
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/BreedListItemContent.kt
@@ -0,0 +1,42 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.lhalegria.dogga.R
+
+@Composable
+fun BreedListItemText(breedName: String, alignment: Alignment.Horizontal) {
+ Column(
+ modifier = Modifier.padding(8.dp),
+ horizontalAlignment = alignment
+ ) {
+ Text(
+ text = breedName.replaceFirstChar { it.uppercaseChar() },
+ color = MaterialTheme.colorScheme.onPrimary,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+}
+
+@Composable
+fun BreedListItemIcon() {
+ Image(
+ painterResource(R.drawable.ic_dog_face),
+ contentDescription = stringResource(id = R.string.dog_icon_content_description),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(50.dp)
+ .padding(2.dp)
+ )
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/EmptyDataBox.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/EmptyDataBox.kt
new file mode 100644
index 0000000..1beb1d4
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/EmptyDataBox.kt
@@ -0,0 +1,38 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+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.TextUnit
+import androidx.compose.ui.unit.TextUnitType
+import androidx.compose.ui.unit.dp
+import dev.lhalegria.dogga.R
+import dev.lhalegria.dogga.view.ui.theme.CaramelStrong
+
+@Composable
+fun EmptyDataBox(action: () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ modifier = Modifier.padding(32.dp),
+ fontSize = TextUnit(15f, TextUnitType.Sp),
+ textAlign = TextAlign.Center,
+ color = CaramelStrong,
+ text = stringResource(id = R.string.empty_data_message)
+ )
+
+ TryAgainButton(modifier = Modifier) {
+ action.invoke()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/ErrorBox.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/ErrorBox.kt
new file mode 100644
index 0000000..ae3c2cf
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/ErrorBox.kt
@@ -0,0 +1,48 @@
+package dev.lhalegria.dogga.view.composable
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.TextUnitType
+import androidx.compose.ui.unit.dp
+import dev.lhalegria.dogga.R
+import dev.lhalegria.dogga.view.ui.theme.CaramelStrong
+
+@Composable
+fun ErrorBox(errorCause: String, action: () -> Unit) {
+ if (errorCause.isNotEmpty()) {
+ Toast.makeText(
+ LocalContext.current,
+ String.format(stringResource(R.string.error_cause), errorCause),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ modifier = Modifier.padding(32.dp),
+ fontSize = TextUnit(15f, TextUnitType.Sp),
+ textAlign = TextAlign.Center,
+ color = CaramelStrong,
+ text = stringResource(id = R.string.error_message)
+ )
+
+ TryAgainButton(modifier = Modifier) {
+ action.invoke()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Loading.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Loading.kt
new file mode 100644
index 0000000..ed1da4c
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Loading.kt
@@ -0,0 +1,27 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun LoadingBox() {
+ Box(
+ Modifier.fillMaxSize()
+ .background(color = MaterialTheme.colorScheme.surface)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(50.dp),
+ progress = 1f
+ )
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Navigation.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Navigation.kt
new file mode 100644
index 0000000..99e2b29
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Navigation.kt
@@ -0,0 +1,31 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import dev.lhalegria.dogga.model.BreedModel
+
+private const val BREED_LIST_ROUTE = "breeds_list"
+private const val BREED_NAME_ARG = "_arg_breed_name"
+const val BREED_DETAIL_ROUTE = "breed_details"
+
+@Composable
+fun AppNavigation(breeds: List = listOf()) {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = BREED_LIST_ROUTE) {
+ composable(BREED_LIST_ROUTE) {
+ BreedList(breeds, navController)
+ }
+ composable(
+ route = "$BREED_DETAIL_ROUTE/{$BREED_NAME_ARG}",
+ arguments = listOf(navArgument(BREED_NAME_ARG) {
+ type = NavType.StringType
+ })
+ ) {
+ BreedDetail(it.arguments?.getString(BREED_NAME_ARG).orEmpty(), navController)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Toolbar.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Toolbar.kt
new file mode 100644
index 0000000..52f1245
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/Toolbar.kt
@@ -0,0 +1,39 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.lhalegria.dogga.R
+import dev.lhalegria.dogga.view.ui.theme.Caramel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun Toolbar(title: String, icon: ImageVector, iconClickAction: () -> Unit) {
+ TopAppBar(
+ navigationIcon = {
+ Icon(
+ icon,
+ String.format(stringResource(id = R.string.app_toolbar_content_description), title),
+ Modifier
+ .padding(12.dp)
+ .clickable { iconClickAction.invoke() }
+ )
+ },
+ title = { Text(title) },
+ colors = TopAppBarDefaults.smallTopAppBarColors(
+ containerColor = Caramel,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White
+ )
+ )
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/TryAgainButton.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/TryAgainButton.kt
new file mode 100644
index 0000000..fc21b77
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/composable/TryAgainButton.kt
@@ -0,0 +1,30 @@
+package dev.lhalegria.dogga.view.composable
+
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+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.res.stringResource
+import dev.lhalegria.dogga.R
+import dev.lhalegria.dogga.view.ui.theme.Caramel
+
+@Composable
+fun TryAgainButton(
+ modifier: Modifier,
+ onClick: () -> Unit
+) {
+ Button(
+ onClick = { onClick() },
+ modifier = modifier,
+ colors = ButtonDefaults.buttonColors(
+ contentColor = Color.White,
+ containerColor = Caramel,
+ disabledContentColor = Color.White,
+ disabledContainerColor = Color.Gray
+ )
+ ) {
+ Text(text = stringResource(id = R.string.try_again))
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/preview/Previews.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/preview/Previews.kt
new file mode 100644
index 0000000..97a6627
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/preview/Previews.kt
@@ -0,0 +1,60 @@
+package dev.lhalegria.dogga.view.preview
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import dev.lhalegria.dogga.model.BreedModel
+import dev.lhalegria.dogga.view.composable.BreedDetail
+import dev.lhalegria.dogga.view.composable.BreedList
+import dev.lhalegria.dogga.view.composable.EmptyDataBox
+import dev.lhalegria.dogga.view.composable.ErrorBox
+import dev.lhalegria.dogga.view.composable.LoadingBox
+import dev.lhalegria.dogga.view.ui.theme.DoggaTheme
+
+val dogs = listOf(
+ BreedModel("vira-lata", ""),
+ BreedModel("salsicha", ""),
+ BreedModel("pit-bull", ""),
+ BreedModel("pastor alemão", ""),
+ BreedModel("lulu da pomerania", ""),
+ BreedModel("pintcher", ""),
+)
+
+@Preview(showBackground = true)
+@Composable
+fun BreedLoadingPreview() {
+ DoggaTheme {
+ LoadingBox()
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun BreedEmptyDataPreview() {
+ DoggaTheme {
+ EmptyDataBox {}
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun BreedErrorDataPreview() {
+ DoggaTheme {
+ ErrorBox {}
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun BreedListPreview() {
+ DoggaTheme {
+ BreedList(dogs = dogs, null)
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun BreedDetailsPreview() {
+ DoggaTheme {
+ BreedDetail("", null)
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Color.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Color.kt
new file mode 100644
index 0000000..46c1cfd
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Color.kt
@@ -0,0 +1,10 @@
+package dev.lhalegria.dogga.view.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Caramel = Color(0xFFA58D11)
+val CaramelStrong = Color(0xFF756304)
+val CaramelSuperStrong = Color(0xFF3C3200)
+
+val SurfaceVariantLight = Color(0xFFD1CAA4)
+val SurfaceVariantDark = Color(0xFF332B00)
diff --git a/app/src/main/java/dev/lhalegria/dogga/ui/theme/Theme.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Theme.kt
similarity index 56%
rename from app/src/main/java/dev/lhalegria/dogga/ui/theme/Theme.kt
rename to app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Theme.kt
index 5351f76..49a185a 100644
--- a/app/src/main/java/dev/lhalegria/dogga/ui/theme/Theme.kt
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Theme.kt
@@ -1,58 +1,45 @@
-package dev.lhalegria.dogga.ui.theme
+package dev.lhalegria.dogga.view.ui.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.Color
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
+ primary = Caramel,
+ onPrimary = Color.White,
+ secondary = CaramelStrong,
+ tertiary = CaramelSuperStrong,
+ surface = Color.Black,
+ surfaceVariant = SurfaceVariantDark
)
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),
- */
+ primary = Caramel,
+ onPrimary = Color.DarkGray,
+ secondary = CaramelStrong,
+ tertiary = CaramelSuperStrong,
+ surface = Color.White,
+ surfaceVariant = SurfaceVariantLight
)
@Composable
fun DoggaTheme(
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 {
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Type.kt b/app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Type.kt
new file mode 100644
index 0000000..ed8c83a
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/view/ui/theme/Type.kt
@@ -0,0 +1,17 @@
+package dev.lhalegria.dogga.view.ui.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
+
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+)
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/viewmodel/BreedViewModel.kt b/app/src/main/kotlin/dev/lhalegria/dogga/viewmodel/BreedViewModel.kt
new file mode 100644
index 0000000..100dd55
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/viewmodel/BreedViewModel.kt
@@ -0,0 +1,57 @@
+package dev.lhalegria.dogga.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dev.lhalegria.dogga.model.BreedModel
+import dev.lhalegria.dogga.repository.IBreedRepository
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+class BreedViewModel(
+ private val repository: IBreedRepository
+): ViewModel() {
+
+ private val _breedStateFlow = MutableStateFlow>>(RequestState.Loading)
+ val breedStateFlow: StateFlow>>
+ get() = _breedStateFlow
+
+ private val _subBreedStateFlow = MutableStateFlow>>(RequestState.Loading)
+ val subBreedStateFlow: StateFlow>>
+ get() = _subBreedStateFlow
+
+ private val _breedImageStateFlow = MutableStateFlow>(RequestState.Loading)
+ val breedImageStateFlow: StateFlow>
+ get() = _breedImageStateFlow
+
+ fun getBreeds() = viewModelScope.launch(IO) {
+ repository.getBreeds()
+ .onSuccess {
+ _breedStateFlow.value = RequestState.Success(it)
+ }
+ .onFailure {
+ _breedStateFlow.value = RequestState.Error(it)
+ }
+ }
+
+ fun getSubBreed(breed: String) = viewModelScope.launch(IO) {
+ repository.getSubBreed(breed)
+ .onSuccess {
+ _subBreedStateFlow.value = RequestState.Success(it)
+ }
+ .onFailure {
+ _subBreedStateFlow.value = RequestState.Error(it)
+ }
+ }
+
+ fun getBreedImage(breed: String) = viewModelScope.launch(IO) {
+ repository.getBreedImage(breed)
+ .onSuccess {
+ _breedImageStateFlow.emit(RequestState.Success(it))
+ }
+ .onFailure {
+ _breedImageStateFlow.emit(RequestState.Error(it))
+ }
+ }
+}
diff --git a/app/src/main/kotlin/dev/lhalegria/dogga/viewmodel/RequestState.kt b/app/src/main/kotlin/dev/lhalegria/dogga/viewmodel/RequestState.kt
new file mode 100644
index 0000000..766c56e
--- /dev/null
+++ b/app/src/main/kotlin/dev/lhalegria/dogga/viewmodel/RequestState.kt
@@ -0,0 +1,13 @@
+package dev.lhalegria.dogga.viewmodel
+
+sealed class RequestState {
+
+ object Loading : RequestState()
+
+ data class Success(val data: T) : RequestState()
+
+ data class Error(
+ val t: Throwable,
+ var consumed: Boolean = false
+ ) : RequestState()
+}
diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
index 7706ab9..61bb4c2 100644
--- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -1,30 +1,11 @@
-
-
-
-
-
-
-
-
+ android:width="100dp"
+ android:height="87dp"
+ android:viewportWidth="512"
+ android:viewportHeight="449.32">
+
+ android:fillType="evenOdd" />
diff --git a/app/src/main/res/drawable/ic_dog_face.xml b/app/src/main/res/drawable/ic_dog_face.xml
new file mode 100644
index 0000000..80f9249
--- /dev/null
+++ b/app/src/main/res/drawable/ic_dog_face.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 07d5da9..d1163de 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -5,166 +5,6 @@
android:viewportWidth="108"
android:viewportHeight="108">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_placeholder.xml b/app/src/main/res/drawable/ic_placeholder.xml
new file mode 100644
index 0000000..c6f398d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_placeholder.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_toolbar_icon.xml b/app/src/main/res/drawable/ic_toolbar_icon.xml
new file mode 100644
index 0000000..cc7bdb2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_toolbar_icon.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index b3e26b4..036d09b 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,5 @@
-
-
-
-
+
+
+
\ 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
index b3e26b4..036d09b 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,6 +1,5 @@
-
-
-
-
+
+
+
\ 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
index c209e78..46ab92c 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..d9490e8
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.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
index b2dfe3d..200cb85 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp 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
index 4f0f1d6..8e7bad0 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..7a7dfab
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.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
index 62b611d..cd3c939 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp 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
index 948a307..cc835aa 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..31155f1
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.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
index 1b9a695..3cc4073 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp 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
index 28d4b77..8daf1f7 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..fc1023e
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.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
index 9287f50..e41f0ae 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp 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
index aa7d642..c95828b 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..4f24503
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.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
index 9126ae3..58f1ecb 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp 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
index ca1931b..2224d23 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,4 +7,7 @@
#FF018786
#FF000000
#FFFFFFFF
+
+ #FFA58D11
+ #FF756304
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..26ef28d
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #A58D11
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b2c6085..eab49c8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,14 @@
Dogga
-
\ No newline at end of file
+ There are no data to show
+ There was an error while trying to get the data
+ Try again
+ The Response has no body
+ The response has failure with code: %d
+ Dog breeds list
+ Dog breed details
+ Picture of a dog from the %s breed.
+ Dogga Application Toolbar with title %s
+ An icon representing a dog
+ The error cause was: %s
+
diff --git a/app/src/test/java/dev/lhalegria/dogga/ExampleUnitTest.kt b/app/src/test/java/dev/lhalegria/dogga/ExampleUnitTest.kt
deleted file mode 100644
index f6db59b..0000000
--- a/app/src/test/java/dev/lhalegria/dogga/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package dev.lhalegria.dogga
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/app/src/test/kotlin/dev/lhalegria/dogga/repository/BreedRepositoryTest.kt b/app/src/test/kotlin/dev/lhalegria/dogga/repository/BreedRepositoryTest.kt
new file mode 100644
index 0000000..3aed0a9
--- /dev/null
+++ b/app/src/test/kotlin/dev/lhalegria/dogga/repository/BreedRepositoryTest.kt
@@ -0,0 +1,192 @@
+package dev.lhalegria.dogga.repository
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import dev.lhalegria.dogga.DoggaApp
+import dev.lhalegria.dogga.datasource.response.BreedImageResponse
+import dev.lhalegria.dogga.datasource.response.BreedResponse
+import dev.lhalegria.dogga.datasource.service.BreedService
+import dev.lhalegria.dogga.exception.ResponseErrorException
+import dev.lhalegria.dogga.exception.ResponseNoBodyException
+import dev.lhalegria.dogga.model.BreedModel
+import dev.lhalegria.dogga.model.mapper.BreedMapper
+import dev.lhalegria.dogga.model.mapper.IBreedMapper
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import retrofit2.Response
+
+@ExperimentalCoroutinesApi
+class BreedRepositoryTest {
+
+ @get:Rule
+ val rule = InstantTaskExecutorRule()
+
+ private val service = mockk()
+
+ private val mapper: IBreedMapper by lazy {
+ BreedMapper()
+ }
+
+ private val repository: IBreedRepository by lazy {
+ BreedRepository(service, mapper)
+ }
+
+ @Before
+ fun setup() {
+ mockkObject(DoggaApp)
+ every { DoggaApp.appResources.getString(any()) } returns MOCK_STRING_RES
+ }
+
+ @Test
+ fun `GIVEN service getBreeds returns success WHEN calls getBreeds THEN returns success`() = runTest {
+ // GIVEN - Configuration
+ val mockResponse = BreedResponse(
+ breedsList = listOf("Spitz", "Pit-Bull"),
+ status = "success"
+ )
+
+ val expectedResponse = Result.success(mapper.breedsResponseToModel(mockResponse))
+ coEvery { service.getBreeds() } returns Response.success(mockResponse)
+
+ // WHEN - Action/Execution
+ val response = repository.getBreeds()
+
+ // THEN - Assertion
+ coVerify { service.getBreeds() }
+ assertEquals(expectedResponse, response)
+ }
+
+ @Test
+ fun `GIVEN service getBreeds returns success AND body is null WHEN calls getBreeds THEN returns no body exception`() = runTest {
+ // GIVEN - Configuration
+ val expectedResponse = Result.failure>(ResponseNoBodyException())
+ coEvery { service.getBreeds() } returns Response.success(null)
+
+ // WHEN - Action/Execution
+ val response = repository.getBreeds()
+
+ // THEN - Assertion
+ coVerify { service.getBreeds() }
+ assertEquals(expectedResponse.toString(), response.toString())
+ }
+
+ @Test
+ fun `GIVEN service getBreeds returns failure WHEN calls getBreeds THEN returns failure`() = runTest {
+ // GIVEN - Configuration
+ val expectedResponse = Result.failure>(ResponseErrorException(MOCK_ERROR_CODE))
+ coEvery { service.getBreeds() } returns Response.error(MOCK_ERROR_CODE, "".toResponseBody())
+
+ // WHEN - Action/Execution
+ val response = repository.getBreeds()
+
+ // THEN - Assertion
+ coVerify { service.getBreeds() }
+ assertEquals(expectedResponse.toString(), response.toString())
+ }
+
+ @Test
+ fun `GIVEN service getSubBreedsFromBreed returns success WHEN calls getSubBreed THEN returns success`() = runTest {
+ // GIVEN - Configuration
+ val mockResponse = BreedResponse(
+ breedsList = listOf("Spitz", "Pit-Bull"),
+ status = "success"
+ )
+
+ val expectedResponse = Result.success(mapper.breedsResponseToModel(mockResponse))
+ coEvery { service.getSubBreedsFromBreed(any()) } returns Response.success(mockResponse)
+
+ // WHEN - Action/Execution
+ val response = repository.getSubBreed(MOCK_BREED)
+
+ // THEN - Assertion
+ coVerify { service.getSubBreedsFromBreed(any()) }
+ assertEquals(expectedResponse, response)
+ }
+
+ @Test
+ fun `GIVEN service getSubBreedsFromBreed returns success AND body is null WHEN calls getSubBreed THEN returns no body exception`() = runTest {
+ // GIVEN - Configuration
+ val expectedResponse = Result.failure>(ResponseNoBodyException())
+ coEvery { service.getSubBreedsFromBreed(any()) } returns Response.success(null)
+
+ // WHEN - Action/Execution
+ val response = repository.getSubBreed(MOCK_BREED)
+
+ // THEN - Assertion
+ coVerify { service.getSubBreedsFromBreed(any()) }
+ assertEquals(expectedResponse.toString(), response.toString())
+ }
+
+ @Test
+ fun `GIVEN service getSubBreedsFromBreed returns failure WHEN calls getSubBreed THEN returns failure`() = runTest {
+ // GIVEN - Configuration
+ val expectedResponse = Result.failure>(ResponseErrorException(MOCK_ERROR_CODE))
+ coEvery { service.getSubBreedsFromBreed(any()) } returns Response.error(MOCK_ERROR_CODE, "".toResponseBody())
+
+ // WHEN - Action/Execution
+ val response = repository.getSubBreed(MOCK_BREED)
+
+ // THEN - Assertion
+ coVerify { service.getSubBreedsFromBreed(any()) }
+ assertEquals(expectedResponse.toString(), response.toString())
+ }
+
+ @Test
+ fun `GIVEN service getBreedImage returns success WHEN calls getBreedImage THEN returns success`() = runTest {
+ // GIVEN - Configuration
+ val mockResponse = BreedImageResponse("http://anyUrl", "success")
+
+ val expectedResponse = Result.success(mockResponse.imageUrl)
+ coEvery { service.getBreedImage(any()) } returns Response.success(mockResponse)
+
+ // WHEN - Action/Execution
+ val response = repository.getBreedImage(MOCK_BREED)
+
+ // THEN - Assertion
+ coVerify { service.getBreedImage(any()) }
+ assertEquals(expectedResponse, response)
+ }
+
+ @Test
+ fun `GIVEN service getBreedImage returns success AND body is null WHEN calls getBreedImage THEN returns no body exception`() = runTest {
+ // GIVEN - Configuration
+ val expectedResponse = Result.failure>(ResponseNoBodyException())
+ coEvery { service.getBreedImage(any()) } returns Response.success(null)
+
+ // WHEN - Action/Execution
+ val response = repository.getBreedImage(MOCK_BREED)
+
+ // THEN - Assertion
+ coVerify { service.getBreedImage(any()) }
+ assertEquals(expectedResponse.toString(), response.toString())
+ }
+
+ @Test
+ fun `GIVEN service getBreedImage returns failure WHEN calls getBreedImage THEN returns failure`() = runTest {
+ // GIVEN - Configuration
+ val expectedResponse = Result.failure>(ResponseErrorException(MOCK_ERROR_CODE))
+ coEvery { service.getBreedImage(any()) } returns Response.error(MOCK_ERROR_CODE, "".toResponseBody())
+
+ // WHEN - Action/Execution
+ val response = repository.getBreedImage(MOCK_BREED)
+
+ // THEN - Assertion
+ coVerify { service.getBreedImage(any()) }
+ assertEquals(expectedResponse.toString(), response.toString())
+ }
+
+ private companion object {
+ const val MOCK_ERROR_CODE = 400
+ const val MOCK_BREED = "cattledog"
+ const val MOCK_STRING_RES = "any string"
+ }
+}
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index 0afbd4d..0000000
--- a/build.gradle
+++ /dev/null
@@ -1,6 +0,0 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-plugins {
- id 'com.android.application' version '8.0.2' apply false
- id 'com.android.library' version '8.0.2' apply false
- id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
-}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..f1273bf
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,21 @@
+import extensions.applyDefault
+
+allprojects {
+ repositories.applyDefault()
+}
+
+tasks.register("clean", Delete::class) {
+ delete(rootProject.buildDir)
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+
+ dependencies {
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.KOTLIN}")
+ }
+}
diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/buildSrc/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
new file mode 100644
index 0000000..0038480
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,22 @@
+import Build_gradle.PluginsVersions.GRADLE_ANDROID
+import Build_gradle.PluginsVersions.KOTLIN
+
+repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+}
+
+plugins {
+ `kotlin-dsl`
+}
+
+object PluginsVersions {
+ const val GRADLE_ANDROID = "8.0.2"
+ const val KOTLIN = "1.8.21"
+}
+
+dependencies {
+ implementation("com.android.tools.build:gradle:$GRADLE_ANDROID")
+ implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN")
+}
diff --git a/buildSrc/src/main/kotlin/BuildSetup.kt b/buildSrc/src/main/kotlin/BuildSetup.kt
new file mode 100644
index 0000000..3541f4f
--- /dev/null
+++ b/buildSrc/src/main/kotlin/BuildSetup.kt
@@ -0,0 +1,16 @@
+object BuildSetup {
+ const val APPLICATION_ID = "dev.lhalegria.dogga"
+ const val NAMESPACE = "dev.lhalegria.dogga"
+
+ const val BUILD_TOOLS_VERSION = "34.0.0"
+ const val COMPILE_SDK_VERSION = 33
+ const val MIN_SDK_VERSION = 30
+ const val TARGET_SDK_VERSION = 33
+
+ const val VERSION_CODE = 1
+ const val VERSION_NAME = "1.0.0"
+
+ const val COMPOSE_KOTLIN_COMPILER = "1.4.7"
+
+ const val TEST_INSTRUMENTATION_RUNNER = "androidx.test.runner.AndroidJUnitRunner"
+}
diff --git a/buildSrc/src/main/kotlin/BuildType.kt b/buildSrc/src/main/kotlin/BuildType.kt
new file mode 100644
index 0000000..7d5f024
--- /dev/null
+++ b/buildSrc/src/main/kotlin/BuildType.kt
@@ -0,0 +1,21 @@
+
+interface BuildType {
+
+ val isMinifyEnabled: Boolean
+
+ companion object {
+ const val DEBUG = "debug"
+ const val RELEASE = "release"
+ }
+}
+
+object BuildTypeDebug : BuildType {
+ override val isMinifyEnabled = false
+
+ const val applicationIdSuffix = ".debug"
+ const val versionNameSuffix = "-DEV"
+}
+
+object BuildTypeRelease : BuildType {
+ override val isMinifyEnabled = true
+}
diff --git a/buildSrc/src/main/kotlin/Modules.kt b/buildSrc/src/main/kotlin/Modules.kt
new file mode 100644
index 0000000..30494db
--- /dev/null
+++ b/buildSrc/src/main/kotlin/Modules.kt
@@ -0,0 +1,3 @@
+object Modules {
+
+}
diff --git a/buildSrc/src/main/kotlin/Plugins.kt b/buildSrc/src/main/kotlin/Plugins.kt
new file mode 100644
index 0000000..75a951e
--- /dev/null
+++ b/buildSrc/src/main/kotlin/Plugins.kt
@@ -0,0 +1,4 @@
+object Plugins {
+ const val ANDROID_APPLICATION = "com.android.application"
+ const val KOTLIN_ANDROID = "android"
+}
diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt
new file mode 100644
index 0000000..bc7f48e
--- /dev/null
+++ b/buildSrc/src/main/kotlin/Versions.kt
@@ -0,0 +1,20 @@
+object Versions {
+ const val ANDROIDX_CORE = "1.10.1"
+ const val ANDROIDX_LIFECYCLE = "2.6.1"
+ const val ANDROIDX_TEST = "1.1.5"
+ const val COIL = "2.4.0"
+ const val COMPOSE_ACTIVITY = "1.7.2"
+ const val COMPOSE_BOM = "2022.10.00"
+ const val COMPOSE_LIFECYCLE = "2.6.1"
+ const val COMPOSE_NAVIGATION = "2.6.0"
+ const val CORE_TESTING = "2.2.0"
+ const val COROUTINES_TEST = "1.7.0"
+ const val ESPRESSO = "3.5.1"
+ const val JUNIT = "4.13.2"
+ const val KOIN = "3.4.2"
+ const val KOIN_COMPOSE = "3.4.5"
+ const val KOTLIN = "1.8.21"
+ const val MOCKK = "1.13.5"
+ const val OKHTTP = "4.11.0"
+ const val RETROFIT = "2.9.0"
+}
diff --git a/buildSrc/src/main/kotlin/dependencies/AndroidX.kt b/buildSrc/src/main/kotlin/dependencies/AndroidX.kt
new file mode 100644
index 0000000..a248b34
--- /dev/null
+++ b/buildSrc/src/main/kotlin/dependencies/AndroidX.kt
@@ -0,0 +1,16 @@
+object AndroidX {
+ const val CORE = "androidx.core:core-ktx:${Versions.ANDROIDX_CORE}"
+ const val LIFECYCLE = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.ANDROIDX_LIFECYCLE}"
+
+ object Compose {
+ const val BOM = "androidx.compose:compose-bom:${Versions.COMPOSE_BOM}"
+ const val ACTIVITY = "androidx.activity:activity-compose:${Versions.COMPOSE_ACTIVITY}"
+ const val LIFECYCLE = "androidx.lifecycle:lifecycle-runtime-compose:${Versions.COMPOSE_LIFECYCLE}"
+ const val UI = "androidx.compose.ui:ui"
+ const val UI_GRAPHICS = "androidx.compose.ui:ui-graphics"
+ const val UI_TOOLING = "androidx.compose.ui:ui-tooling"
+ const val PREVIEW = "androidx.compose.ui:ui-tooling-preview"
+ const val MATERIAL_3 = "androidx.compose.material3:material3"
+ const val NAVIGATION = "androidx.navigation:navigation-compose:${Versions.COMPOSE_NAVIGATION}"
+ }
+}
diff --git a/buildSrc/src/main/kotlin/dependencies/Koin.kt b/buildSrc/src/main/kotlin/dependencies/Koin.kt
new file mode 100644
index 0000000..d0e2a49
--- /dev/null
+++ b/buildSrc/src/main/kotlin/dependencies/Koin.kt
@@ -0,0 +1,4 @@
+object Koin {
+ const val KOIN = "io.insert-koin:koin-android:${Versions.KOIN}"
+ const val KOIN_COMPOSE = "io.insert-koin:koin-androidx-compose:${Versions.KOIN_COMPOSE}"
+}
diff --git a/buildSrc/src/main/kotlin/dependencies/Other.kt b/buildSrc/src/main/kotlin/dependencies/Other.kt
new file mode 100644
index 0000000..296086e
--- /dev/null
+++ b/buildSrc/src/main/kotlin/dependencies/Other.kt
@@ -0,0 +1,4 @@
+object Other {
+ const val COIL = "io.coil-kt:coil-compose:${Versions.COIL}"
+ const val KOTLIN_BOM = "org.jetbrains.kotlin:kotlin-bom:${Versions.KOTLIN}"
+}
diff --git a/buildSrc/src/main/kotlin/dependencies/Square.kt b/buildSrc/src/main/kotlin/dependencies/Square.kt
new file mode 100644
index 0000000..9df5c89
--- /dev/null
+++ b/buildSrc/src/main/kotlin/dependencies/Square.kt
@@ -0,0 +1,6 @@
+object Square {
+ const val RETROFIT = "com.squareup.retrofit2:retrofit:${Versions.RETROFIT}"
+ const val OKHTTP = "com.squareup.okhttp3:okhttp:${Versions.OKHTTP}"
+ const val OKHTTP_LOG_INTERCEPTOR = "com.squareup.okhttp3:logging-interceptor:${Versions.OKHTTP}"
+ const val GSON_CONVERTER = "com.squareup.retrofit2:converter-gson:${Versions.RETROFIT}"
+}
diff --git a/buildSrc/src/main/kotlin/dependencies/Test.kt b/buildSrc/src/main/kotlin/dependencies/Test.kt
new file mode 100644
index 0000000..96fa506
--- /dev/null
+++ b/buildSrc/src/main/kotlin/dependencies/Test.kt
@@ -0,0 +1,10 @@
+object Test {
+ const val JUNIT = "junit:junit:${Versions.JUNIT}"
+ const val ANDROIDX_JUNIT = "androidx.test.ext:junit:${Versions.ANDROIDX_TEST}"
+ const val ESPRESSO = "androidx.test.espresso:espresso-core:${Versions.ESPRESSO}"
+ const val CORE_TESTING = "androidx.arch.core:core-testing:${Versions.CORE_TESTING}"
+ const val COROUTINES = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.COROUTINES_TEST}"
+ const val COMPOSE_UI_JUNIT4 = "androidx.compose.ui:ui-test-junit4"
+ const val COMPOSE_TEST_MANIFEST = "androidx.compose.ui:ui-test-manifest"
+ const val MOCKK = "io.mockk:mockk:${Versions.MOCKK}"
+}
diff --git a/buildSrc/src/main/kotlin/extensions/RepositoryHandlerExtension.kt b/buildSrc/src/main/kotlin/extensions/RepositoryHandlerExtension.kt
new file mode 100644
index 0000000..f43dca1
--- /dev/null
+++ b/buildSrc/src/main/kotlin/extensions/RepositoryHandlerExtension.kt
@@ -0,0 +1,10 @@
+package extensions
+
+import org.gradle.api.artifacts.dsl.RepositoryHandler
+import org.gradle.kotlin.dsl.maven
+
+fun RepositoryHandler.applyDefault() {
+ google()
+ maven("https://maven.google.com/")
+ maven("https://plugins.gradle.org/m2/")
+}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index a9ba4cb..6533669 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Thu Jun 22 17:33:00 BRT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index effc6b3..0000000
--- a/settings.gradle
+++ /dev/null
@@ -1,16 +0,0 @@
-pluginManagement {
- repositories {
- google()
- mavenCentral()
- gradlePluginPortal()
- }
-}
-dependencyResolutionManagement {
- repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
- repositories {
- google()
- mavenCentral()
- }
-}
-rootProject.name = "Dogga"
-include ':app'
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..d81fa9a
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,3 @@
+rootProject.name = "Dogga"
+rootProject.buildFileName = "build.gradle.kts"
+include(":app")