Implement core:config module

This commit is contained in:
Shcherbatykh Oleg
2026-02-18 14:02:04 +03:00
parent 341d128099
commit 705b689c58
30 changed files with 392 additions and 41 deletions

View File

@@ -61,6 +61,7 @@ dependencies {
implementation(project(":core:common"))
implementation(project(":core:network"))
implementation(project(":core:database"))
implementation(project(":core:config"))
implementation(project(":feature:schedule"))
implementation(project(":feature:progress"))

View File

@@ -5,7 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".App"
android:name=".TSUDeskApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -13,14 +13,14 @@
android:supportsRtl="true"
android:theme="@style/Theme.TSUDesk">
<activity
android:name=".MainActivity"
android:name=".SplashScreenActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".MainActivity" />
</application>
</manifest>

View File

@@ -1,7 +1,24 @@
package ru.fincode.tsudesk
import android.app.Application
import android.util.Log
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.fincode.tsudesk.core.config.domain.usecase.GetConfigUseCase
import ru.fincode.tsudesk.core.common.model.DataResult
import javax.inject.Inject
const val LOG_TAG = "NETWORK_DEBUG"
@HiltAndroidApp
class App : Application()
class TSUDeskApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
}
}

View File

@@ -11,7 +11,6 @@ import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType
import ru.fincode.tsudesk.feature.schedule.domain.usecase.GetScheduleUseCase
import javax.inject.Inject
private const val LOG_TAG = "NETWORK_DEBUG"
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

View File

@@ -0,0 +1,44 @@
package ru.fincode.tsudesk
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.core.config.domain.usecase.GetConfigUseCase
import javax.inject.Inject
@AndroidEntryPoint
class SplashScreenActivity : ComponentActivity() {
@Inject
lateinit var fetchConfigUseCase: GetConfigUseCase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launchWhenCreated {
val result = withContext(Dispatchers.IO) { fetchConfigUseCase() }
when (result) {
is DataResult.Data -> {
Log.d(LOG_TAG, "SUCCESS: config=${result.data}")
}
is DataResult.Error -> {
Log.e(
LOG_TAG,
"ERROR: ${result.error}, fallback=${result.data}",
result.cause
)
}
}
startActivity(Intent(this@SplashScreenActivity, MainActivity::class.java))
finish()
}
}
}

View File

@@ -1,6 +1,12 @@
package ru.fincode.tsudesk.core.common.model
sealed interface DataResult<out T> {
data class Data<T>(val data: T, val refreshedFromNetwork: Boolean) : DataResult<T>
data class Error(val error: AppError, val cause: Throwable? = null) : DataResult<Nothing>
}
data class Data<T>(
val data: T, val refreshedFromNetwork: Boolean
) : DataResult<T>
data class Error<T>(
val error: AppError, val data: T? = null, val cause: Throwable? = null
) : DataResult<T>
}

View File

@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "ru.fincode.tsudesk.core.config"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions {
sourceCompatibility = jvm
targetCompatibility = jvm
}
kotlinOptions {
jvmTarget = jvm.toString()
}
}
kapt {
correctErrorTypes = true
}
dependencies {
kapt(libs.hilt.compiler)
implementation(libs.hilt.android)
// Kotlin Serialization
implementation(libs.kotlinx.serialization.json)
implementation(project(":core:network"))
implementation(project(":core:common"))
}

View File

@@ -0,0 +1,63 @@
package ru.fincode.tsudesk.core.config.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.core.config.data.datasource.ConfigRemoteDataSource
import ru.fincode.tsudesk.core.config.domain.ConfigRepository
import ru.fincode.tsudesk.core.config.domain.model.AppConfig
import ru.fincode.tsudesk.core.network.model.NetworkResult
import javax.inject.Inject
class ConfigRepositoryImpl @Inject constructor(
private val remoteDataSource: ConfigRemoteDataSource
) : ConfigRepository {
private val defaultConfig = AppConfig()
override suspend fun fetchConfig(): DataResult<AppConfig> {
return when (val res = remoteDataSource.load()) {
is NetworkResult.Success -> {
val dto = res.data
DataResult.Data(
data = AppConfig(
newsEnabled = dto.newsEnabled ?: defaultConfig.newsEnabled,
scheduleEnabled = dto.scheduleEnabled ?: defaultConfig.scheduleEnabled,
gradesEnabled = dto.gradesEnabled ?: defaultConfig.gradesEnabled
),
refreshedFromNetwork = true
)
}
is NetworkResult.Error -> {
DataResult.Error(
error = res.error.toAppError(), data = defaultConfig
)
}
}
}
override fun getConfig(): Flow<DataResult<AppConfig>> = flow {
when (val result = remoteDataSource.load()) {
is NetworkResult.Success -> {
val dto = result.data
val appConfig = AppConfig(
newsEnabled = dto.newsEnabled ?: defaultConfig.newsEnabled,
scheduleEnabled = dto.scheduleEnabled ?: defaultConfig.scheduleEnabled,
gradesEnabled = dto.gradesEnabled ?: defaultConfig.gradesEnabled
)
emit(DataResult.Data(data = appConfig, refreshedFromNetwork = true))
}
is NetworkResult.Error -> {
emit(
DataResult.Error(
error = result.error.toAppError(),
data = defaultConfig
)
)
}
}
}
}

View File

@@ -0,0 +1,8 @@
package ru.fincode.tsudesk.core.config.data.datasource
import ru.fincode.tsudesk.core.config.data.remote.dto.RemoteConfigDto
import ru.fincode.tsudesk.core.network.model.NetworkResult
interface ConfigRemoteDataSource {
suspend fun load(): NetworkResult<RemoteConfigDto>
}

View File

@@ -0,0 +1,15 @@
package ru.fincode.tsudesk.core.config.data.datasource
import ru.fincode.tsudesk.core.config.data.remote.api.ConfigApi
import ru.fincode.tsudesk.core.config.data.remote.dto.RemoteConfigDto
import ru.fincode.tsudesk.core.network.apiCall
import ru.fincode.tsudesk.core.network.model.NetworkResult
import javax.inject.Inject
class ConfigRemoteDataSourceImpl @Inject constructor(
private val api: ConfigApi
) : ConfigRemoteDataSource {
override suspend fun load(): NetworkResult<RemoteConfigDto> =
apiCall { api.getConfig() }
}

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk.core.config.data.model
data class RemoteConfig(
val newsEnabled: Boolean,
val scheduleEnabled: Boolean,
val gradesEnabled: Boolean
)

View File

@@ -0,0 +1,10 @@
package ru.fincode.tsudesk.core.config.data.remote
object ConfigApiContract {
const val CONFIG_BASE_URL = "https://scherbatykh.ru/app/tsudesk/"
object Path {
const val GET_CONFIG_METHOD = "config.json"
}
}

View File

@@ -0,0 +1,12 @@
package ru.fincode.tsudesk.core.config.data.remote.api
import retrofit2.Response
import retrofit2.http.GET
import ru.fincode.tsudesk.core.config.data.remote.ConfigApiContract.Path.GET_CONFIG_METHOD
import ru.fincode.tsudesk.core.config.data.remote.dto.RemoteConfigDto
interface ConfigApi {
@GET(GET_CONFIG_METHOD)
suspend fun getConfig(): Response<RemoteConfigDto>
}

View File

@@ -0,0 +1,10 @@
package ru.fincode.tsudesk.core.config.data.remote.dto
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RemoteConfigDto(
val newsEnabled: Boolean? = null,
val scheduleEnabled: Boolean? = null,
val gradesEnabled: Boolean? = null
)

View File

@@ -0,0 +1,35 @@
package ru.fincode.tsudesk.core.config.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import ru.fincode.tsudesk.core.config.data.remote.ConfigApiContract.CONFIG_BASE_URL
import ru.fincode.tsudesk.core.network.RetrofitFactory
import ru.fincode.tsudesk.core.config.data.remote.api.ConfigApi
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ConfigNetworkModule {
@Provides
@Singleton
@ConfigRetrofit
fun provideConfigRetrofit(
factory: RetrofitFactory,
client: OkHttpClient
): Retrofit =
factory.create(
baseUrl = CONFIG_BASE_URL,
client = client
)
@Provides
@Singleton
fun provideConfigApi(
@ConfigRetrofit retrofit: Retrofit
): ConfigApi =
retrofit.create(ConfigApi::class.java)
}

View File

@@ -0,0 +1,28 @@
package ru.fincode.tsudesk.core.config.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.fincode.tsudesk.core.config.data.ConfigRepositoryImpl
import ru.fincode.tsudesk.core.config.data.datasource.ConfigRemoteDataSource
import ru.fincode.tsudesk.core.config.data.datasource.ConfigRemoteDataSourceImpl
import ru.fincode.tsudesk.core.config.domain.ConfigRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ConfigRepositoryModule {
@Binds
@Singleton
abstract fun bindRemoteDataSource(
impl: ConfigRemoteDataSourceImpl
): ConfigRemoteDataSource
@Binds
@Singleton
abstract fun bindRepository(
impl: ConfigRepositoryImpl
): ConfigRepository
}

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk.core.config.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ConfigRetrofit

View File

@@ -0,0 +1,10 @@
package ru.fincode.tsudesk.core.config.domain
import kotlinx.coroutines.flow.Flow
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.core.config.domain.model.AppConfig
interface ConfigRepository {
suspend fun fetchConfig(): DataResult<AppConfig>
fun getConfig(): Flow<DataResult<AppConfig>>
}

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk.core.config.domain.model
data class AppConfig(
val newsEnabled: Boolean = false,
val scheduleEnabled: Boolean = false,
val gradesEnabled: Boolean = false
)

View File

@@ -0,0 +1,13 @@
package ru.fincode.tsudesk.core.config.domain.usecase
import kotlinx.coroutines.flow.Flow
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.core.config.domain.ConfigRepository
import ru.fincode.tsudesk.core.config.domain.model.AppConfig
import javax.inject.Inject
class GetConfigUseCase @Inject constructor(
private val repository: ConfigRepository
) {
suspend operator fun invoke(): DataResult<AppConfig> = repository.fetchConfig()
}

View File

@@ -18,7 +18,7 @@ import ru.fincode.tsudesk.core.database.schedule.ScheduleDao
// NewsEntity::class,
// GradeEntity::class,
],
version = 1,
version = 2,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {

View File

@@ -13,7 +13,9 @@ android {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = false
@@ -32,7 +34,9 @@ android {
jvmTarget = jvm.toString()
}
}
kapt {
correctErrorTypes = true
}
dependencies {
kapt(libs.hilt.compiler)
implementation(libs.hilt.android)

View File

@@ -31,7 +31,6 @@ android {
jvmTarget = jvm.toString()
}
}
dependencies {
implementation(libs.core.ktx)
implementation(libs.androidx.appcompat)

View File

@@ -11,7 +11,6 @@ android {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false

View File

@@ -30,7 +30,6 @@ android {
jvmTarget = jvm.toString()
}
}
dependencies {
implementation(libs.core.ktx)
implementation(libs.androidx.appcompat)

View File

@@ -33,7 +33,9 @@ android {
jvmTarget = jvm.toString()
}
}
kapt {
correctErrorTypes = true
}
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.material)

View File

@@ -1,7 +1,6 @@
package ru.fincode.tsudesk.feature.schedule.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import ru.fincode.tsudesk.core.common.model.DataResult
@@ -23,18 +22,29 @@ class ScheduleRepositoryImpl @Inject constructor(
) : ScheduleRepository {
override fun observeSchedule(type: ScheduleType): Flow<DataResult<ScheduleEntity>> = flow {
val key = when (type) {
is ScheduleType.Group -> ScheduleCacheKey.group(type.number)
is ScheduleType.Teacher -> ScheduleCacheKey.teacher(type.name)
val scheduleKey = type.toCacheKey()
val cached = local.observeSchedule(scheduleKey).first()
cached?.let {
emit(DataResult.Data(it, refreshedFromNetwork = false))
}
val cached: ScheduleEntity? = local.observeSchedule(key).first()
if (cached != null) {
emit(DataResult.Data(cached, refreshedFromNetwork = false))
val networkResult = loadSchedule(type)
if (networkResult is NetworkResult.Error) {
emit(DataResult.Error(networkResult.error.toAppError()))
return@flow
}
// if (cached?.timestamp != updated.timestamp) { ... }
val networkResult: NetworkResult<ScheduleEntity> = when (type) {
val networkSchedule = (networkResult as NetworkResult.Success).data
local.saveSchedule(scheduleKey, networkSchedule)
val updated = local.loadSchedule(scheduleKey)
if (updated != null && cached?.timestamp != updated.timestamp) {
emit(DataResult.Data(updated, refreshedFromNetwork = true))
}
}
private suspend fun loadSchedule(type: ScheduleType): NetworkResult<ScheduleEntity> =
when (type) {
is ScheduleType.Group ->
remote.loadScheduleByGroup(type.number).map(mapper::invoke)
@@ -42,18 +52,4 @@ class ScheduleRepositoryImpl @Inject constructor(
remote.loadScheduleByTeacher(type.name).map(mapper::invoke)
}
when (networkResult) {
is NetworkResult.Success -> {
local.saveSchedule(key, networkResult.data)
}
is NetworkResult.Error -> {
emit(DataResult.Error(networkResult.error.toAppError()))
return@flow
}
}
val updated: ScheduleEntity = local.observeSchedule(key).filterNotNull().first()
val refreshedFromNetwork = cached?.timestamp != updated.timestamp
emit(DataResult.Data(updated, refreshedFromNetwork))
}
}

View File

@@ -1,6 +1,18 @@
package ru.fincode.tsudesk.feature.schedule.domain.model
import ru.fincode.tsudesk.feature.schedule.data.local.ScheduleCacheKey
sealed interface ScheduleType {
data class Group(val number: String) : ScheduleType
data class Teacher(val name: String) : ScheduleType
}
fun toCacheKey(): String
data class Group(val number: String) : ScheduleType {
override fun toCacheKey(): String =
ScheduleCacheKey.group(number)
}
data class Teacher(val name: String) : ScheduleType {
override fun toCacheKey(): String =
ScheduleCacheKey.teacher(name)
}
}

View File

@@ -8,6 +8,7 @@ versionCode = "1"
agp = "8.12.0"
kotlin = "1.9.24"
jvmTarget = "17"
serilization = "1.6.3"
coreKtx = "1.10.1"
appcompat = "1.6.1"
@@ -24,12 +25,16 @@ lifecycle = "2.7.0"
coroutines = "1.8.1"
room = "2.6.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
[libraries]
# Android
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serilization" }
# UI: AndroidX, Jetpack Compose
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
@@ -51,11 +56,15 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx"
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
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" }
[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }

View File

@@ -27,6 +27,7 @@ include(":core:common")
include(":core:ui")
include(":core:network")
include(":core:database")
include(":core:config")
include(":feature:schedule")
include(":feature:news")