Add rest of the implementation

This commit is contained in:
Leonardo Murça 2025-03-17 19:09:19 -03:00
parent b6a511165b
commit 5b890d495f
22 changed files with 454 additions and 0 deletions

View file

@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
id("com.google.dagger.hilt.android")
}
android {
@ -63,6 +64,9 @@ dependencies {
implementation(libs.coil.kt.compose)
implementation(libs.retrofit2.retrofit)
implementation(libs.retrofit.retrofit2.kotlinx.serialization.converter)
implementation(libs.hilt.android)
implementation (libs.okhttp)
kapt(libs.hilt.android.compiler)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View file

@ -2,7 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".MainApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View file

@ -4,18 +4,29 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint
import xyz.leomurca.rickandmorty.data.CharacterRepository
import xyz.leomurca.rickandmorty.ui.home.HomeScreen
import xyz.leomurca.rickandmorty.ui.home.HomeViewModel
import xyz.leomurca.rickandmorty.ui.theme.RickAndMortyTheme
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel: HomeViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RickAndMortyTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
HomeScreen(viewModel)
}
}
}

View file

@ -1,5 +1,7 @@
package xyz.leomurca.rickandmorty
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MainApplication : Application()

View file

@ -0,0 +1,7 @@
package xyz.leomurca.rickandmorty.data
import xyz.leomurca.rickandmorty.data.model.Character
interface CharacterRepository {
suspend fun characters(): CharacterResult<List<Character>>
}

View file

@ -0,0 +1,6 @@
package xyz.leomurca.rickandmorty.data
sealed class CharacterResult<out T> {
data class Success<T>(val data: T) : CharacterResult<T>()
data class Error(val message: String) : CharacterResult<Nothing>()
}

View file

@ -0,0 +1,7 @@
package xyz.leomurca.rickandmorty.data.model
data class Character(
val id: Int,
val name: String,
val image: String
)

View file

@ -0,0 +1,39 @@
package xyz.leomurca.rickandmorty.data.remote
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import xyz.leomurca.rickandmorty.data.CharacterRepository
import xyz.leomurca.rickandmorty.data.CharacterResult
import xyz.leomurca.rickandmorty.data.model.Character
import xyz.leomurca.rickandmorty.di.AppDispatchers
import xyz.leomurca.rickandmorty.di.Dispatcher
import xyz.leomurca.rickandmorty.network.NetworkDataSource
import xyz.leomurca.rickandmorty.network.NetworkResult
class RemoteCharacterRepository(
@Dispatcher(AppDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
private val dataSource: NetworkDataSource,
) : CharacterRepository {
override suspend fun characters(): CharacterResult<List<Character>> {
return withContext(ioDispatcher) {
when (val result = dataSource.characters()) {
is NetworkResult.Success -> {
CharacterResult.Success(
result.data.results.map {
Character(
id = it.id,
name = it.name,
image = it.image
)
}
)
}
is NetworkResult.Error -> {
CharacterResult.Error(result.errorMessage)
}
}
}
}
}

View file

@ -0,0 +1,25 @@
package xyz.leomurca.rickandmorty.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import xyz.leomurca.rickandmorty.data.CharacterRepository
import xyz.leomurca.rickandmorty.data.remote.RemoteCharacterRepository
import xyz.leomurca.rickandmorty.network.NetworkDataSource
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class DataModule {
@Provides
@Singleton
fun providesMovieRepository(
@Dispatcher(AppDispatchers.IO) ioDispatcher: CoroutineDispatcher,
dataSource: NetworkDataSource,
): CharacterRepository {
return RemoteCharacterRepository(ioDispatcher, dataSource)
}
}

View file

@ -0,0 +1,30 @@
package xyz.leomurca.rickandmorty.di
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.Qualifier
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Dispatcher(val appDispatcher: AppDispatchers)
enum class AppDispatchers {
Default,
IO,
}
@Module
@InstallIn(SingletonComponent::class)
object DispatchersModule {
@Provides
@Dispatcher(AppDispatchers.IO)
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Dispatcher(AppDispatchers.Default)
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}

View file

@ -0,0 +1,50 @@
package xyz.leomurca.rickandmorty.di
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import xyz.leomurca.rickandmorty.BuildConfig
import xyz.leomurca.rickandmorty.network.NetworkDataSource
import xyz.leomurca.rickandmorty.network.remote.ApiService
import xyz.leomurca.rickandmorty.network.remote.RemoteNetworkDataSource
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object NetworkModule {
@Provides
@Singleton
fun providesNetworkJson(): Json = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}
@Provides
@Singleton
fun provideNetworkDataSource(apiService: ApiService, json: Json): NetworkDataSource {
return RemoteNetworkDataSource(apiService, json)
}
@Provides
@Singleton
fun provideRetrofit(json: Json): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}

View file

@ -0,0 +1,8 @@
package xyz.leomurca.rickandmorty.network
import xyz.leomurca.rickandmorty.network.model.NetworkCharacter
import xyz.leomurca.rickandmorty.network.model.NetworkCharacterResponse
interface NetworkDataSource {
suspend fun characters(): NetworkResult<NetworkCharacterResponse>
}

View file

@ -0,0 +1,6 @@
package xyz.leomurca.rickandmorty.network
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val errorMessage: String) : NetworkResult<Nothing>()
}

View file

@ -0,0 +1,10 @@
package xyz.leomurca.rickandmorty.network.model
import kotlinx.serialization.Serializable
@Serializable
data class NetworkCharacter(
val id: Int,
val name: String,
val image: String
)

View file

@ -0,0 +1,8 @@
package xyz.leomurca.rickandmorty.network.model
import kotlinx.serialization.Serializable
@Serializable
data class NetworkCharacterResponse(
val results: List<NetworkCharacter>,
)

View file

@ -0,0 +1,9 @@
package xyz.leomurca.rickandmorty.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class NetworkError(
@SerialName("error") val message: String
)

View file

@ -0,0 +1,12 @@
package xyz.leomurca.rickandmorty.network.remote
import retrofit2.Response
import retrofit2.http.GET
import xyz.leomurca.rickandmorty.network.model.NetworkCharacter
import xyz.leomurca.rickandmorty.network.model.NetworkCharacterResponse
interface ApiService {
@GET("character")
suspend fun characters(): Response<NetworkCharacterResponse>
}

View file

@ -0,0 +1,29 @@
package xyz.leomurca.rickandmorty.network.remote
import kotlinx.serialization.json.Json
import xyz.leomurca.rickandmorty.network.NetworkDataSource
import xyz.leomurca.rickandmorty.network.NetworkResult
import xyz.leomurca.rickandmorty.network.model.NetworkCharacter
import xyz.leomurca.rickandmorty.network.model.NetworkCharacterResponse
import xyz.leomurca.rickandmorty.network.model.NetworkError
class RemoteNetworkDataSource(
private val apiService: ApiService,
private val json: Json
): NetworkDataSource {
override suspend fun characters(): NetworkResult<NetworkCharacterResponse> {
return try {
val response = apiService.characters()
if (response.isSuccessful) {
NetworkResult.Success(response.body() ?: throw Exception("Empty response body"))
} else {
val errorBody = response.errorBody()?.string() ?: ""
val networkError = json.decodeFromString<NetworkError>(errorBody)
NetworkResult.Error(networkError.message)
}
} catch (e: Exception) {
NetworkResult.Error(e.message.toString())
}
}
}

View file

@ -0,0 +1,142 @@
package xyz.leomurca.rickandmorty.ui.home
import android.util.Log
import xyz.leomurca.rickandmorty.data.model.Character as Character
import android.widget.Space
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.material3.Card
import androidx.compose.material3.CardElevation
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val state = viewModel.uiState.collectAsState()
when (val value = state.value) {
is HomeViewModel.UiState.Loading -> CharacterCardSkeleton()
is HomeViewModel.UiState.Loaded.Success -> CharacterStaggeredGrid(value.characters)
is HomeViewModel.UiState.Loaded.Error -> Text(value.message)
}
}
@Composable
fun CharacterStaggeredGrid(characters: List<Character>) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(minSize = 150.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(characters.size) { index ->
CharacterCard(character = characters[index])
}
}
}
@Composable
fun CharacterCard(character: Character) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(8.dp)
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(character.image)
.crossfade(true)
.build(),
contentDescription = character.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = character.name)
}
}
}
@Composable
fun CharacterCardSkeleton() {
val shimmerColors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f),
)
val transition = rememberInfiniteTransition()
val translateAnim = transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
easing = LinearEasing
)
)
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset.Zero,
end = Offset(x = translateAnim.value, y = translateAnim.value)
)
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(8.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.background(brush = brush, shape = RectangleShape)
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.width(80.dp)
.height(16.dp)
.background(brush = brush, shape = RectangleShape)
)
}
}
}

View file

@ -0,0 +1,39 @@
package xyz.leomurca.rickandmorty.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import xyz.leomurca.rickandmorty.data.CharacterRepository
import xyz.leomurca.rickandmorty.data.CharacterResult
import xyz.leomurca.rickandmorty.data.model.Character
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
private val characterRepository: CharacterRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
init {
viewModelScope.launch {
_uiState.value = when (val result = characterRepository.characters()) {
is CharacterResult.Success -> UiState.Loaded.Success(result.data)
is CharacterResult.Error -> UiState.Loaded.Error(result.message)
}
}
}
sealed interface UiState {
data object Loading : UiState
sealed class Loaded : UiState {
data class Success(val characters: List<Character>) : Loaded()
data class Error(val message: String) : Loaded()
}
}
}

View file

@ -4,4 +4,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
id("com.google.dagger.hilt.android") version "2.51.1" apply false
}

View file

@ -1,5 +1,6 @@
[versions]
agp = "8.8.2"
hiltAndroid = "2.51.1"
kotlin = "2.0.20"
coreKtx = "1.15.0"
junit = "4.13.2"
@ -11,11 +12,14 @@ composeBom = "2025.03.00"
navigation = "2.8.9"
kotlinxSerializationJson = "1.8.0"
coil = "3.1.0"
okhttp = "4.12.0"
retrofit = "1.0.0"
retrofit2 = "2.11.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" }
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" }
@ -33,6 +37,7 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
coil-kt = { group = "io.coil-kt.coil3", name = "coil", version.ref = "coil" }
coil-kt-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
retrofit2-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit2" }
retrofit-retrofit2-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofit" }