feat: add network, dto, mapping and repository initial setup

This commit is contained in:
Leonardo Murça 2025-07-17 21:15:36 -03:00
parent 7eb061fc9d
commit 3fd7e78090
24 changed files with 492 additions and 3 deletions

View file

@ -1,11 +1,13 @@
import com.android.sdklib.AndroidVersion.ApiBaseExtension.BAKLAVA import com.android.sdklib.AndroidVersion.ApiBaseExtension.BAKLAVA
import com.android.sdklib.AndroidVersion.ApiBaseExtension.VANILLA_ICE_CREAM import com.android.sdklib.AndroidVersion.ApiBaseExtension.VANILLA_ICE_CREAM
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt.android) alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
} }
@ -22,6 +24,8 @@ android {
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"${getSecret("API_BASE_URL")}\"")
buildConfigField("String", "ACCESS_TOKEN", "\"${getSecret("ACCESS_TOKEN")}\"")
} }
buildTypes { buildTypes {
@ -45,6 +49,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }
@ -61,6 +66,10 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.retrofit2)
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
@ -70,3 +79,22 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
} }
fun getSecret(key: String): String {
// 1. Check Gradle -P property (CI)
project.findProperty(key)?.let { return it.toString() }
// 2. Check environment variable (CI)
System.getenv(key)?.let { return it }
// 3. Fallback to local.properties (local dev)
val localPropsFile = rootProject.file("local.properties")
if (localPropsFile.exists()) {
val props = Properties().apply {
load(localPropsFile.inputStream())
}
return props.getProperty(key) ?: ""
}
return ""
}

View file

@ -2,7 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".CSGOApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@ -11,7 +14,6 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.CSGOMatches" android:theme="@style/Theme.CSGOMatches"
android:name=".CSGOApplication"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View file

@ -0,0 +1,64 @@
package xyz.leomurca.csgomatches.data.local
import xyz.leomurca.csgomatches.data.model.LeagueDto
import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.data.model.OpponentDto
import xyz.leomurca.csgomatches.data.model.OpponentRecord
import xyz.leomurca.csgomatches.data.model.SerieDto
import xyz.leomurca.csgomatches.data.source.MatchDataSource
import xyz.leomurca.csgomatches.domain.model.Resource
class MatchLocalDataSource : MatchDataSource {
override suspend fun upcomingMatches(): Resource<List<MatchDto>> {
return Resource.Success(
data = listOf(
MatchDto(
beginAt = "2025-07-27T10:30:00Z",
opponents = emptyList(),
league = LeagueDto(
id = 5078,
name = "United21",
imageUrl = "https://cdn.pandascore.co/images/league/image/5078/800px-united21_allmode-png"
),
serie = SerieDto(
id = 9519,
fullName = "Season 35 2025"
),
status = "not_started"
),
MatchDto(
beginAt = "2025-07-21T10:30:00Z",
opponents = listOf(
OpponentDto(
type = "team",
opponent = OpponentRecord(
id = 128519,
name = "GenOne",
imageUrl = "https://cdn.pandascore.co/images/team/image/128519/genone_csgo.png"
)
),
OpponentDto(
type = "team",
opponent = OpponentRecord(
id = 134996,
name = "VOLT",
imageUrl = "https://cdn.pandascore.co/images/team/image/134996/127px_volt_2024_allmode.png"
)
)
),
league = LeagueDto(
id = 5078,
name = "United21",
imageUrl = "https://cdn.pandascore.co/images/league/image/5078/800px-united21_allmode-png"
),
serie = SerieDto(
id = 9519,
fullName = "Season 35 2025"
),
status = "not_started"
),
)
)
}
}

View file

@ -0,0 +1,30 @@
package xyz.leomurca.csgomatches.data.mapper
import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.domain.model.League
import xyz.leomurca.csgomatches.domain.model.Match
import xyz.leomurca.csgomatches.domain.model.Opponent
import xyz.leomurca.csgomatches.domain.model.Serie
fun MatchDto.toDomain(): Match {
return Match(
beginAt = beginAt ?: "",
opponents = opponents.map { op ->
Opponent(
id = op.opponent.id,
name = op.opponent.name ?: "",
imageUrl = op.opponent.imageUrl ?: ""
)
},
league = League(
id = league.id,
name = league.name ?: "",
imageUrl = league.imageUrl ?: ""
),
serie = Serie(
id = serie.id,
name = serie.fullName ?: ""
),
status = status ?: ""
)
}

View file

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

View file

@ -0,0 +1,21 @@
package xyz.leomurca.csgomatches.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LeagueDto(
val id: Long,
val name: String?,
@SerialName("image_url")
val imageUrl: String?
)
//"league": {
// "id": 5078,
// "name": "United21",
// "url": null,
// "slug": "cs-go-united21",
// "modified_at": "2023-12-22T16:36:10Z",
// "image_url": "https://cdn.pandascore.co/images/league/image/5078/800px-united21_allmode-png"
//},

View file

@ -0,0 +1,17 @@
package xyz.leomurca.csgomatches.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MatchDto(
@SerialName("begin_at")
val beginAt: String?,
val opponents: List<OpponentDto>,
val league: LeagueDto,
val serie: SerieDto,
val status: String?
)

View file

@ -0,0 +1,31 @@
package xyz.leomurca.csgomatches.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OpponentDto(
val type: String?,
val opponent: OpponentRecord
)
@Serializable
data class OpponentRecord(
val id: Long,
val name: String?,
@SerialName("image_url")
val imageUrl: String?
)
//{
// "type": "Team",
// "opponent": {
// "id": 126694,
// "name": "BIG Academy",
// "location": "DE",
// "slug": "big-academy",
// "modified_at": "2025-07-17T10:49:15Z",
// "acronym": "BIG.A",
// "image_url": "https://cdn.pandascore.co/images/team/image/126694/big.png"
// }
//}

View file

@ -0,0 +1,26 @@
package xyz.leomurca.csgomatches.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SerieDto(
val id: Long,
@SerialName("full_name")
val fullName: String?
)
//"serie": {
// "id": 9519,
// "name": "",
// "year": 2025,
// "begin_at": "2025-07-18T08:00:00Z",
// "end_at": "2025-08-04T21:00:00Z",
// "winner_id": null,
// "winner_type": "Team",
// "slug": "cs-go-united21-35-2025",
// "modified_at": "2025-07-17T10:55:39Z",
// "league_id": 5078,
// "season": "35",
// "full_name": "Season 35 2025"
//},

View file

@ -0,0 +1,15 @@
package xyz.leomurca.csgomatches.data.remote
import okhttp3.Interceptor
import okhttp3.Response
class AuthorizationInterceptor(
private val token: String
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
return chain.proceed(request)
}
}

View file

@ -0,0 +1,28 @@
package xyz.leomurca.csgomatches.data.remote
import kotlinx.serialization.json.Json
import xyz.leomurca.csgomatches.data.model.ErrorDto
import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.data.source.MatchDataSource
import xyz.leomurca.csgomatches.domain.model.Resource
class MatchRemoteDataSourceImpl(
private val matchesApiService: MatchesApiService,
private val json: Json
) : MatchDataSource {
override suspend fun upcomingMatches(): Resource<List<MatchDto>> {
return try {
val response = matchesApiService.upcomingMatches()
if (response.isSuccessful) {
Resource.Success(response.body() ?: throw Exception("Empty response body"))
} else {
val errorBody = response.errorBody()?.string() ?: ""
val networkError = json.decodeFromString<ErrorDto>(errorBody)
Resource.Error(networkError.message)
}
} catch (e: Exception) {
Resource.Error(e.message.toString())
}
}
}

View file

@ -0,0 +1,11 @@
package xyz.leomurca.csgomatches.data.remote
import retrofit2.Response
import retrofit2.http.GET
import xyz.leomurca.csgomatches.data.model.MatchDto
interface MatchesApiService {
@GET("matches")
suspend fun upcomingMatches(): Response<List<MatchDto>>
}

View file

@ -0,0 +1,25 @@
package xyz.leomurca.csgomatches.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import xyz.leomurca.csgomatches.data.mapper.toDomain
import xyz.leomurca.csgomatches.data.source.MatchDataSource
import xyz.leomurca.csgomatches.di.AppDispatchers
import xyz.leomurca.csgomatches.di.Dispatcher
import xyz.leomurca.csgomatches.domain.model.Match
import xyz.leomurca.csgomatches.domain.model.Resource
import xyz.leomurca.csgomatches.domain.repository.MatchRepository
class MatchRepositoryImpl(
@Dispatcher(AppDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
private val matchRemoteDataSource: MatchDataSource
) : MatchRepository {
override suspend fun upcomingMatches(): Resource<List<Match>> {
return withContext(ioDispatcher) {
when (val result = matchRemoteDataSource.upcomingMatches()) {
is Resource.Success -> Resource.Success(result.data.map { it.toDomain() })
is Resource.Error -> Resource.Error(result.message)
}
}
}
}

View file

@ -0,0 +1,8 @@
package xyz.leomurca.csgomatches.data.source
import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.domain.model.Resource
interface MatchDataSource {
suspend fun upcomingMatches(): Resource<List<MatchDto>>
}

View file

@ -0,0 +1,30 @@
package xyz.leomurca.csgomatches.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import jakarta.inject.Qualifier
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@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,89 @@
package xyz.leomurca.csgomatches.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.coroutines.CoroutineDispatcher
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import retrofit2.Retrofit
import xyz.leomurca.csgomatches.data.remote.MatchesApiService
import javax.inject.Singleton
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import xyz.leomurca.csgomatches.BuildConfig
import xyz.leomurca.csgomatches.data.local.MatchLocalDataSource
import xyz.leomurca.csgomatches.data.remote.AuthorizationInterceptor
import xyz.leomurca.csgomatches.data.remote.MatchRemoteDataSourceImpl
import xyz.leomurca.csgomatches.data.repository.MatchRepositoryImpl
import xyz.leomurca.csgomatches.data.source.MatchDataSource
import xyz.leomurca.csgomatches.domain.repository.MatchRepository
@Module
@InstallIn(SingletonComponent::class)
internal object NetworkModule {
@Provides
@Singleton
fun providesNetworkJson(): Json = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}
@Provides
@Singleton
fun provideAuthorizationInterceptor(): Interceptor {
return AuthorizationInterceptor(BuildConfig.ACCESS_TOKEN)
}
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: Interceptor): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
}
@Provides
@Singleton
fun provideRetrofit(json: Json, okHttpClient: OkHttpClient): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
}
@Provides
@Singleton
fun provideMatchesApiService(retrofit: Retrofit): MatchesApiService {
return retrofit.create(MatchesApiService::class.java)
}
@Provides
@Singleton
fun provideMatchRemoteDataSource(
matchesApiService: MatchesApiService,
json: Json
): MatchDataSource {
val useRemote = false
return if (useRemote) {
MatchRemoteDataSourceImpl(matchesApiService, json)
} else {
MatchLocalDataSource()
}
}
@Provides
@Singleton
fun providesMatchRepository(
@Dispatcher(AppDispatchers.IO) ioDispatcher: CoroutineDispatcher,
matchRemoteDataSource: MatchDataSource
): MatchRepository {
return MatchRepositoryImpl(ioDispatcher, matchRemoteDataSource)
}
}

View file

@ -0,0 +1,7 @@
package xyz.leomurca.csgomatches.domain.model
data class League(
val id: Long,
val name: String,
val imageUrl: String
)

View file

@ -0,0 +1,10 @@
package xyz.leomurca.csgomatches.domain.model
data class Match(
val beginAt: String,
val opponents: List<Opponent>,
val league: League,
val serie: Serie,
val status: String
)

View file

@ -0,0 +1,7 @@
package xyz.leomurca.csgomatches.domain.model
data class Opponent(
val id: Long,
val name: String,
val imageUrl: String
)

View file

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

View file

@ -0,0 +1,6 @@
package xyz.leomurca.csgomatches.domain.model
data class Serie(
val id: Long,
val name: String
)

View file

@ -0,0 +1,9 @@
package xyz.leomurca.csgomatches.domain.repository
import xyz.leomurca.csgomatches.domain.model.Match
import xyz.leomurca.csgomatches.domain.model.Resource
interface MatchRepository {
suspend fun upcomingMatches(): Resource<List<Match>>
}

View file

@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.hilt.android) apply false alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
} }

View file

@ -12,6 +12,10 @@ hilt = "2.56.2"
ksp = "2.2.0-2.0.2" ksp = "2.2.0-2.0.2"
navigation = "2.9.2" navigation = "2.9.2"
splashScreen = "1.0.1" splashScreen = "1.0.1"
retrofit = "1.0.0"
retrofit2 = "2.11.0"
kotlinxSerializationJson = "1.8.1"
okhttp = "4.12.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -28,10 +32,14 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 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-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
hilt-android = { group = "com.google.dagger", name="hilt-android", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" }
retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit2" }
retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofit" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@ -39,5 +47,6 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }