Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/news-base
# Conflicts: # feature/news/build.gradle.kts
This commit is contained in:
@@ -12,10 +12,8 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "ru.fincode.tsudesk"
|
applicationId = "ru.fincode.tsudesk"
|
||||||
|
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
|
|
||||||
versionCode = libs.versions.versionCode.get().toInt()
|
versionCode = libs.versions.versionCode.get().toInt()
|
||||||
versionName = libs.versions.versionName.get()
|
versionName = libs.versions.versionName.get()
|
||||||
}
|
}
|
||||||
@@ -29,6 +27,7 @@ android {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
|
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = jvm
|
sourceCompatibility = jvm
|
||||||
@@ -37,36 +36,37 @@ android {
|
|||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = jvm.toString()
|
jvmTarget = jvm.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kapt {
|
kapt {
|
||||||
correctErrorTypes = true
|
correctErrorTypes = true
|
||||||
}
|
}
|
||||||
dependencies {
|
|
||||||
implementation(libs.androidx.appcompat)
|
|
||||||
implementation(libs.material)
|
|
||||||
implementation(libs.androidx.activity)
|
|
||||||
implementation(libs.androidx.constraintlayout)
|
|
||||||
|
|
||||||
|
dependencies {
|
||||||
// Compose
|
// Compose
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
|
|
||||||
// Navigation Compose
|
// Navigation Compose
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
implementation(libs.androidx.navigation.common)
|
|
||||||
|
|
||||||
kapt(libs.hilt.compiler)
|
// DI: Hilt
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
|
kapt(libs.hilt.compiler)
|
||||||
|
|
||||||
implementation(libs.okhttp)
|
// Modules
|
||||||
implementation(libs.retrofit)
|
|
||||||
|
|
||||||
implementation(projects.core.common)
|
implementation(projects.core.common)
|
||||||
implementation(projects.core.config)
|
implementation(projects.core.config)
|
||||||
implementation(projects.core.navigation)
|
implementation(projects.core.navigation)
|
||||||
@@ -76,4 +76,7 @@ dependencies {
|
|||||||
implementation(projects.feature.schedule)
|
implementation(projects.feature.schedule)
|
||||||
implementation(projects.feature.progress)
|
implementation(projects.feature.progress)
|
||||||
implementation(projects.feature.news)
|
implementation(projects.feature.news)
|
||||||
|
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ object AppConfigModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideAppConfig(): AppConfig {
|
fun provideAppConfig(): AppConfig {
|
||||||
val baseUrl = if (BuildConfig.DEBUG) BASE_URL_DEVELOP else BASE_URL_PROD
|
val baseUrl = if (BuildConfig.DEBUG) BASE_URL_PROD else BASE_URL_PROD
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
isDebug = BuildConfig.DEBUG,
|
isDebug = BuildConfig.DEBUG,
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<!-- Base application theme. -->
|
<style name="Theme.TSUDesk" parent="android:Theme.Material.NoActionBar" />
|
||||||
<style name="Theme.TSUDesk" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
|
||||||
<!-- Primary brand color. -->
|
|
||||||
<item name="colorPrimary">@color/purple_200</item>
|
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
|
||||||
<item name="colorOnPrimary">@color/black</item>
|
|
||||||
<!-- Secondary brand color. -->
|
|
||||||
<item name="colorSecondary">@color/teal_200</item>
|
|
||||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
|
||||||
<item name="colorOnSecondary">@color/black</item>
|
|
||||||
<!-- Status bar color. -->
|
|
||||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
</style>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,16 +1,3 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<!-- Base application theme. -->
|
<style name="Theme.TSUDesk" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
<style name="Theme.TSUDesk" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
|
||||||
<!-- Primary brand color. -->
|
|
||||||
<item name="colorPrimary">@color/purple_500</item>
|
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
|
||||||
<item name="colorOnPrimary">@color/white</item>
|
|
||||||
<!-- Secondary brand color. -->
|
|
||||||
<item name="colorSecondary">@color/teal_200</item>
|
|
||||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
|
||||||
<item name="colorOnSecondary">@color/black</item>
|
|
||||||
<!-- Status bar color. -->
|
|
||||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
</style>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
package ru.fincode.tsudesk.core.common.model
|
package ru.fincode.tsudesk.core.common.model
|
||||||
|
|
||||||
sealed interface AppError {
|
sealed interface AppError {
|
||||||
data object NoInternet : AppError
|
data object NoInternet : AppError
|
||||||
data object Timeout : AppError
|
data object Timeout : AppError
|
||||||
|
|
||||||
|
/** Временный сбой соединения (EOF / unexpected end of stream / reset) */
|
||||||
|
data object Temporary : AppError
|
||||||
|
|
||||||
data class Http(val code: Int) : AppError
|
data class Http(val code: Int) : AppError
|
||||||
data class Unknown(val message: String? = null) : AppError
|
data class Unknown(val message: String? = null) : AppError
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,15 @@ package ru.fincode.tsudesk.core.common.model
|
|||||||
sealed interface DataResult<out T> {
|
sealed interface DataResult<out T> {
|
||||||
|
|
||||||
data class Data<T>(
|
data class Data<T>(
|
||||||
val data: T, val refreshedFromNetwork: Boolean
|
val data: T,
|
||||||
|
val refreshedFromNetwork: Boolean
|
||||||
) : DataResult<T>
|
) : DataResult<T>
|
||||||
|
|
||||||
data class Error<T>(
|
data class Error<T>(
|
||||||
val error: AppError, val data: T? = null, val cause: Throwable? = null
|
val error: AppError,
|
||||||
|
val data: T? = null,
|
||||||
|
val cause: Throwable? = null
|
||||||
) : DataResult<T>
|
) : DataResult<T>
|
||||||
}
|
|
||||||
|
data object Loading : DataResult<Nothing>
|
||||||
|
}
|
||||||
@@ -45,11 +45,11 @@ dependencies {
|
|||||||
api(libs.okhttp)
|
api(libs.okhttp)
|
||||||
|
|
||||||
implementation(libs.okhttp.logging)
|
implementation(libs.okhttp.logging)
|
||||||
implementation(libs.converter.gson)
|
implementation(libs.retrofit.gson)
|
||||||
|
|
||||||
api(libs.moshi)
|
api(libs.moshi)
|
||||||
api(libs.moshiKotlin)
|
api(libs.moshi.kotlin)
|
||||||
api(libs.retrofitMoshi)
|
api(libs.retrofit.moshi)
|
||||||
|
|
||||||
implementation(projects.core.common)
|
implementation(projects.core.common)
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,13 @@ import ru.fincode.tsudesk.core.network.model.NetworkError
|
|||||||
import ru.fincode.tsudesk.core.network.model.NetworkMeta
|
import ru.fincode.tsudesk.core.network.model.NetworkMeta
|
||||||
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
||||||
import ru.fincode.tsudesk.core.network.model.NetworkResult.Success
|
import ru.fincode.tsudesk.core.network.model.NetworkResult.Success
|
||||||
|
import java.io.EOFException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
suspend inline fun <T> apiCall(
|
suspend inline fun <T> apiCall(
|
||||||
crossinline block: suspend () -> Response<T>
|
crossinline block: suspend () -> Response<T>
|
||||||
@@ -51,8 +56,23 @@ suspend inline fun <T> apiCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.toNetworkError(meta: NetworkMeta): NetworkError = when (this) {
|
fun Throwable.toNetworkError(meta: NetworkMeta): NetworkError = when (this) {
|
||||||
is SocketTimeoutException -> NetworkError.Timeout(meta)
|
|
||||||
is IOException -> NetworkError.NoInternet(meta)
|
is UnknownHostException,
|
||||||
|
is ConnectException,
|
||||||
|
is NoRouteToHostException -> NetworkError.NoInternet(meta)
|
||||||
|
|
||||||
|
is SocketTimeoutException,
|
||||||
|
is InterruptedIOException -> NetworkError.Timeout(meta)
|
||||||
|
|
||||||
|
is EOFException -> NetworkError.Temporary(meta)
|
||||||
|
|
||||||
is HttpException -> NetworkError.Http(code(), meta)
|
is HttpException -> NetworkError.Http(code(), meta)
|
||||||
|
|
||||||
|
is IOException -> {
|
||||||
|
val msg = message?.lowercase().orEmpty()
|
||||||
|
if ("unexpected end of stream" in msg || "eof" in msg) NetworkError.Temporary(meta)
|
||||||
|
else NetworkError.Temporary(meta) // любые IO — временные, не "NoInternet"
|
||||||
|
}
|
||||||
|
|
||||||
else -> NetworkError.Unknown(this, meta)
|
else -> NetworkError.Unknown(this, meta)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package ru.fincode.tsudesk.core.network.interceptor
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
|
||||||
|
class RetryInterceptor(
|
||||||
|
private val maxAttempts: Int = 3,
|
||||||
|
private val baseDelayMs: Long = 300L
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
|
||||||
|
// На всякий: ретраим только идемпотентные методы
|
||||||
|
val method = request.method.uppercase()
|
||||||
|
val retryAllowed = method == "GET" || method == "HEAD"
|
||||||
|
|
||||||
|
var attempt = 1
|
||||||
|
var lastException: IOException? = null
|
||||||
|
var lastResponse: Response? = null
|
||||||
|
|
||||||
|
while (attempt <= maxAttempts) {
|
||||||
|
// закрываем предыдущий response, чтобы не текли ресурсы
|
||||||
|
lastResponse?.close()
|
||||||
|
lastResponse = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
|
||||||
|
if (!retryAllowed) return response
|
||||||
|
|
||||||
|
if (!response.shouldRetry()) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нужно ретраить — подождём и попробуем снова
|
||||||
|
if (attempt == maxAttempts) return response
|
||||||
|
|
||||||
|
sleepBackoff(attempt)
|
||||||
|
|
||||||
|
attempt++
|
||||||
|
continue
|
||||||
|
} catch (e: IOException) {
|
||||||
|
lastException = e
|
||||||
|
if (!retryAllowed || attempt == maxAttempts) throw e
|
||||||
|
|
||||||
|
sleepBackoff(attempt)
|
||||||
|
|
||||||
|
attempt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Теоретически не должны попасть сюда
|
||||||
|
lastResponse?.let { return it }
|
||||||
|
lastException?.let { throw it }
|
||||||
|
throw IOException("RetryInterceptor: exhausted attempts without response/exception")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.shouldRetry(): Boolean {
|
||||||
|
return when (code) {
|
||||||
|
408, 429 -> true
|
||||||
|
in 500..599 -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sleepBackoff(attempt: Int) {
|
||||||
|
val delay = baseDelayMs * min(attempt.toLong(), 2L)
|
||||||
|
try {
|
||||||
|
Thread.sleep(delay)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ sealed class NetworkError(open val meta: NetworkMeta) {
|
|||||||
data class NoInternet(override val meta: NetworkMeta) : NetworkError(meta)
|
data class NoInternet(override val meta: NetworkMeta) : NetworkError(meta)
|
||||||
data class Timeout(override val meta: NetworkMeta) : NetworkError(meta)
|
data class Timeout(override val meta: NetworkMeta) : NetworkError(meta)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EOF / unexpected end of stream / reset / обрыв соединения.
|
||||||
|
* Это НЕ NoInternet.
|
||||||
|
*/
|
||||||
|
data class Temporary(override val meta: NetworkMeta) : NetworkError(meta)
|
||||||
|
|
||||||
data class Http(
|
data class Http(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
override val meta: NetworkMeta
|
override val meta: NetworkMeta
|
||||||
@@ -20,7 +26,8 @@ sealed class NetworkError(open val meta: NetworkMeta) {
|
|||||||
fun toAppError(): AppError = when (this) {
|
fun toAppError(): AppError = when (this) {
|
||||||
is NoInternet -> AppError.NoInternet
|
is NoInternet -> AppError.NoInternet
|
||||||
is Timeout -> AppError.Timeout
|
is Timeout -> AppError.Timeout
|
||||||
|
is Temporary -> AppError.Temporary
|
||||||
is Http -> AppError.Http(code)
|
is Http -> AppError.Http(code)
|
||||||
is Unknown -> AppError.Unknown(throwable.message)
|
is Unknown -> AppError.Unknown(throwable.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.material)
|
|
||||||
}
|
}
|
||||||
@@ -65,5 +65,5 @@ dependencies {
|
|||||||
implementation(projects.core.config)
|
implementation(projects.core.config)
|
||||||
implementation(projects.core.ui)
|
implementation(projects.core.ui)
|
||||||
implementation(projects.core.navigation)
|
implementation(projects.core.navigation)
|
||||||
implementation("org.jsoup:jsoup:1.17.2")
|
implementation(libs.jsoup)
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.material)
|
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,8 @@ android {
|
|||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,40 +30,45 @@ android {
|
|||||||
sourceCompatibility = jvm
|
sourceCompatibility = jvm
|
||||||
targetCompatibility = jvm
|
targetCompatibility = jvm
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions { jvmTarget = jvm.toString() }
|
||||||
jvmTarget = jvm.toString()
|
|
||||||
}
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
kapt {
|
|
||||||
correctErrorTypes = true
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.androidx.appcompat)
|
|
||||||
implementation(libs.material)
|
|
||||||
|
|
||||||
// Compose
|
kapt { correctErrorTypes = true }
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Kotlin
|
||||||
|
implementation(libs.kotlinx.collections.immutable)
|
||||||
|
|
||||||
|
// Compose UI
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
|
|
||||||
// Navigation Compose
|
// Navigation (Compose)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
implementation(libs.androidx.navigation.common)
|
|
||||||
|
|
||||||
kapt(libs.hilt.compiler)
|
// DI
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
|
kapt(libs.hilt.compiler)
|
||||||
implementation(libs.hilt.navigation.compose)
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
|
||||||
implementation(projects.core.network)
|
// Core modules
|
||||||
implementation(projects.core.database)
|
|
||||||
implementation(projects.core.common)
|
implementation(projects.core.common)
|
||||||
implementation(projects.core.config)
|
implementation(projects.core.config)
|
||||||
implementation(projects.core.ui)
|
implementation(projects.core.ui)
|
||||||
implementation(projects.core.navigation)
|
implementation(projects.core.navigation)
|
||||||
|
|
||||||
|
// Data modules used by feature (если feature реально ходит в сеть/бд)
|
||||||
|
implementation(projects.core.network)
|
||||||
|
implementation(projects.core.database)
|
||||||
}
|
}
|
||||||
@@ -22,15 +22,18 @@ class ScheduleRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override fun observeSchedule(type: ScheduleType): Flow<DataResult<ScheduleEntity>> = flow {
|
override fun observeSchedule(type: ScheduleType): Flow<DataResult<ScheduleEntity>> = flow {
|
||||||
val scheduleKey = type.toCacheKey()
|
val scheduleKey = type.toCacheKey()
|
||||||
|
|
||||||
val cached = local.observeSchedule(scheduleKey).first()
|
val cached = local.observeSchedule(scheduleKey).first()
|
||||||
|
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
emit(DataResult.Data(cached, refreshedFromNetwork = false))
|
emit(DataResult.Data(cached, refreshedFromNetwork = false))
|
||||||
if ((System.currentTimeMillis() - cached.timestamp) < CACHE_TTL_MS) return@flow
|
if ((System.currentTimeMillis() - cached.timestamp) < CACHE_TTL_MS) return@flow
|
||||||
|
} else {
|
||||||
|
emit(DataResult.Loading)
|
||||||
}
|
}
|
||||||
|
|
||||||
val networkResult = loadSchedule(type)
|
val networkResult = loadScheduleDto(type).map(mapper::invoke)
|
||||||
if (networkResult is NetworkResult.Error) {
|
if (networkResult is NetworkResult.Error) {
|
||||||
|
if (cached != null) return@flow
|
||||||
emit(DataResult.Error(networkResult.error.toAppError()))
|
emit(DataResult.Error(networkResult.error.toAppError()))
|
||||||
return@flow
|
return@flow
|
||||||
}
|
}
|
||||||
@@ -44,14 +47,15 @@ class ScheduleRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadSchedule(type: ScheduleType): NetworkResult<ScheduleEntity> =
|
private suspend fun loadScheduleDto(
|
||||||
|
type: ScheduleType
|
||||||
|
): NetworkResult<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto> =
|
||||||
when (type) {
|
when (type) {
|
||||||
is ScheduleType.Group -> remote.loadScheduleByGroup(type.number).map(mapper::invoke)
|
is ScheduleType.Group -> remote.loadScheduleByGroup(type.number)
|
||||||
|
is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name)
|
||||||
is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name).map(mapper::invoke)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
private const val CACHE_TTL_MS: Long = 1;//24L * 60L * 60L * 1000L // 1 day
|
private const val CACHE_TTL_MS: Long = 3L * 24L * 60L * 60L * 1000L // 3 дня
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,4 +12,4 @@ class ObserveScheduleUseCase @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
operator fun invoke(type: ScheduleType): Flow<DataResult<ScheduleEntity>> =
|
operator fun invoke(type: ScheduleType): Flow<DataResult<ScheduleEntity>> =
|
||||||
repository.observeSchedule(type)
|
repository.observeSchedule(type)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package ru.fincode.tsudesk.feature.schedule.presentation
|
||||||
|
|
||||||
|
sealed interface ScheduleAction {
|
||||||
|
|
||||||
|
/** Пользователь выбрал группу в выпадающем списке — сразу грузим */
|
||||||
|
data class GroupSelected(val group: String) : ScheduleAction
|
||||||
|
|
||||||
|
/** Кнопка "Показать" (можно оставить как "Повторить") */
|
||||||
|
data object ApplyGroupClicked : ScheduleAction
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package ru.fincode.tsudesk.feature.schedule.presentation.model
|
|
||||||
|
|
||||||
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
|
|
||||||
|
|
||||||
data class ScheduleUiState(
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val data: ScheduleEntity? = null,
|
|
||||||
val errorMessage: String? = null,
|
|
||||||
val title: String = "Schedule",
|
|
||||||
val refreshedFromNetwork: Boolean = false
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.fincode.tsudesk.feature.schedule.presentation.screen
|
||||||
|
|
||||||
|
import ru.fincode.tsudesk.core.common.model.DataResult
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.buildDayStrip
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
|
||||||
|
|
||||||
|
enum class ScheduleViewMode { DAY, WEEK }
|
||||||
|
|
||||||
|
data class ScheduleUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
|
||||||
|
val selectedType: ScheduleType = ScheduleType.Group("220631"),
|
||||||
|
|
||||||
|
val groupInput: String = "220631",
|
||||||
|
val recentGroups: List<String> = listOf("220631", "220632", "220633"),
|
||||||
|
val isGroupMenuExpanded: Boolean = false,
|
||||||
|
|
||||||
|
val viewMode: ScheduleViewMode = ScheduleViewMode.DAY,
|
||||||
|
|
||||||
|
val selectedDate: String = todayString(),
|
||||||
|
val dayStrip: List<String> = buildDayStrip(todayString()),
|
||||||
|
|
||||||
|
val raw: DataResult<ScheduleEntity>? = null,
|
||||||
|
|
||||||
|
val dayLessons: List<UiLesson> = emptyList(),
|
||||||
|
val weekGrouped: List<UiWeekDay> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface ScheduleIntent {
|
||||||
|
data class ChangeGroupInput(val value: String) : ScheduleIntent
|
||||||
|
data object ApplyGroup : ScheduleIntent
|
||||||
|
data class SetGroupMenuExpanded(val expanded: Boolean) : ScheduleIntent
|
||||||
|
data class SelectRecentGroup(val number: String) : ScheduleIntent
|
||||||
|
data class RemoveRecentGroup(val number: String) : ScheduleIntent
|
||||||
|
|
||||||
|
data class SelectDate(val date: String) : ScheduleIntent
|
||||||
|
data class SetViewMode(val mode: ScheduleViewMode) : ScheduleIntent
|
||||||
|
|
||||||
|
data object Retry : ScheduleIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UiLesson(
|
||||||
|
val id: String,
|
||||||
|
val time: String,
|
||||||
|
val title: String,
|
||||||
|
val teacher: String,
|
||||||
|
val room: String,
|
||||||
|
val badgeText: String,
|
||||||
|
val badgeKind: BadgeKind,
|
||||||
|
val isNow: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class BadgeKind { INFO, PRIMARY, LAB, MUTED }
|
||||||
|
|
||||||
|
data class UiWeekDay(
|
||||||
|
val date: String,
|
||||||
|
val title: String,
|
||||||
|
val lessons: List<UiLesson>,
|
||||||
|
)
|
||||||
@@ -3,14 +3,15 @@ package ru.fincode.tsudesk.feature.schedule.presentation.screen
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleViewModel
|
|
||||||
import ru.fincode.tsudesk.feature.schedule.presentation.ui.ScheduleScreen
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScheduleRoute(
|
fun ScheduleRoute(
|
||||||
viewModel: ScheduleViewModel = hiltViewModel()
|
viewModel: ScheduleViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val state = viewModel.state.collectAsStateWithLifecycle().value
|
val state = viewModel.state.collectAsStateWithLifecycle().value
|
||||||
ScheduleScreen(state = state)
|
|
||||||
}
|
ScheduleScreen(
|
||||||
|
state = state,
|
||||||
|
onIntent = viewModel::onIntent
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,27 +1,634 @@
|
|||||||
package ru.fincode.tsudesk.feature.schedule.presentation.ui
|
package ru.fincode.tsudesk.feature.schedule.presentation.screen
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.draw.clip
|
||||||
import ru.fincode.tsudesk.feature.schedule.presentation.model.ScheduleUiState
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.fincode.tsudesk.core.common.model.DataResult
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.*
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.dowRuShort
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScheduleScreen(
|
fun ScheduleScreen(
|
||||||
state: ScheduleUiState
|
state: ScheduleUiState,
|
||||||
|
onIntent: (ScheduleIntent) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Box(
|
Column(modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
ScheduleHeader(state, onIntent)
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
|
when (state.viewMode) {
|
||||||
|
ScheduleViewMode.DAY -> DayPicker(state, onIntent)
|
||||||
|
ScheduleViewMode.WEEK -> WeekHeaderStub(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleContent(
|
||||||
|
state = state,
|
||||||
|
onIntent = onIntent,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScheduleHeader(
|
||||||
|
state: ScheduleUiState,
|
||||||
|
onIntent: (ScheduleIntent) -> Unit
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
|
val headerBrush = Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
MaterialTheme.colorScheme.error,
|
||||||
|
MaterialTheme.colorScheme.error.copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(headerBrush)
|
||||||
|
.padding(top = 20.dp, start = 16.dp, end = 16.dp, bottom = 14.dp)
|
||||||
) {
|
) {
|
||||||
if (state.isLoading) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CircularProgressIndicator()
|
Column(Modifier.weight(1f)) {
|
||||||
} else {
|
Text(
|
||||||
Text(text = state.title, color = Color.Black)
|
text = "Расписание",
|
||||||
|
color = MaterialTheme.colorScheme.onError,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "ТулГУ • Осенний семестр",
|
||||||
|
color = MaterialTheme.colorScheme.onError.copy(alpha = 0.8f),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ModeToggle(
|
||||||
|
mode = state.viewMode,
|
||||||
|
onMode = { onIntent(ScheduleIntent.SetViewMode(it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
GroupSelector(
|
||||||
|
groupInput = state.groupInput,
|
||||||
|
recentGroups = state.recentGroups,
|
||||||
|
expanded = state.isGroupMenuExpanded,
|
||||||
|
isLoading = state.isLoading,
|
||||||
|
onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) },
|
||||||
|
onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) },
|
||||||
|
|
||||||
|
// выбор из списка -> сразу загрузка
|
||||||
|
onSelectRecent = { group ->
|
||||||
|
onIntent(ScheduleIntent.SelectRecentGroup(group))
|
||||||
|
onIntent(ScheduleIntent.ApplyGroup)
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemoveRecent = { onIntent(ScheduleIntent.RemoveRecentGroup(it)) },
|
||||||
|
onApply = { onIntent(ScheduleIntent.ApplyGroup) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ModeToggle(
|
||||||
|
mode: ScheduleViewMode,
|
||||||
|
onMode: (ScheduleViewMode) -> Unit
|
||||||
|
) {
|
||||||
|
val containerShape = RoundedCornerShape(12.dp)
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.onError.copy(alpha = 0.18f), containerShape)
|
||||||
|
.padding(4.dp)
|
||||||
|
) {
|
||||||
|
SegButton(
|
||||||
|
text = "День",
|
||||||
|
selected = mode == ScheduleViewMode.DAY,
|
||||||
|
onClick = { onMode(ScheduleViewMode.DAY) }
|
||||||
|
)
|
||||||
|
SegButton(
|
||||||
|
text = "Неделя",
|
||||||
|
selected = mode == ScheduleViewMode.WEEK,
|
||||||
|
onClick = { onMode(ScheduleViewMode.WEEK) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SegButton(
|
||||||
|
text: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
containerColor = if (selected) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onError.copy(
|
||||||
|
alpha = 0f
|
||||||
|
),
|
||||||
|
contentColor = if (selected) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onError.copy(
|
||||||
|
alpha = 0.85f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text = text, fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupSelector(
|
||||||
|
groupInput: String,
|
||||||
|
recentGroups: List<String>,
|
||||||
|
expanded: Boolean,
|
||||||
|
isLoading: Boolean,
|
||||||
|
onExpanded: (Boolean) -> Unit,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
onSelectRecent: (String) -> Unit,
|
||||||
|
onRemoveRecent: (String) -> Unit,
|
||||||
|
onApply: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
Column(modifier) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = groupInput,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = true,
|
||||||
|
label = { Text("Группа") },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
keyboard?.hide()
|
||||||
|
onExpanded(false)
|
||||||
|
if (groupInput.isNotBlank()) onApply()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { onExpanded(!expanded) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||||
|
contentDescription = if (expanded) "Свернуть список" else "Развернуть список",
|
||||||
|
tint = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.35f),
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.25f),
|
||||||
|
focusedLabelColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.85f),
|
||||||
|
unfocusedLabelColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.7f),
|
||||||
|
focusedTextColor = MaterialTheme.colorScheme.onError,
|
||||||
|
unfocusedTextColor = MaterialTheme.colorScheme.onError,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ Меню РЕАЛЬНО под полем, не перекрывает, фокус в поле работает
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { onExpanded(false) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (recentGroups.isEmpty()) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Нет сохранённых групп") },
|
||||||
|
onClick = { onExpanded(false) },
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
recentGroups.forEach { num ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
onClick = {
|
||||||
|
onSelectRecent(num)
|
||||||
|
onExpanded(false)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = num,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// ✅ trailingIcon — крестик НЕ вызывает onClick элемента меню
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onRemoveRecent(num) }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = "Удалить"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
keyboard?.hide()
|
||||||
|
onExpanded(false)
|
||||||
|
onApply()
|
||||||
|
},
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.onError,
|
||||||
|
contentColor = MaterialTheme.colorScheme.error,
|
||||||
|
disabledContainerColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.6f),
|
||||||
|
disabledContentColor = MaterialTheme.colorScheme.error.copy(alpha = 0.6f),
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text("Загрузка…", fontWeight = FontWeight.Bold)
|
||||||
|
} else {
|
||||||
|
Text("Показать", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayPicker(
|
||||||
|
state: ScheduleUiState,
|
||||||
|
onIntent: (ScheduleIntent) -> Unit
|
||||||
|
) {
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp)
|
||||||
|
.horizontalScroll(scroll)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
state.dayStrip.forEach { date ->
|
||||||
|
DayChip(
|
||||||
|
date = date,
|
||||||
|
selected = date == state.selectedDate,
|
||||||
|
onClick = { onIntent(ScheduleIntent.SelectDate(date)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayChip(
|
||||||
|
date: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
val bg = if (selected) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.surface
|
||||||
|
val fg =
|
||||||
|
if (selected) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onSurface
|
||||||
|
val border =
|
||||||
|
if (selected) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
|
||||||
|
val dayNum = date.take(2)
|
||||||
|
val dow = dowRuShort(date)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = shape,
|
||||||
|
color = bg,
|
||||||
|
tonalElevation = if (selected) 2.dp else 0.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 64.dp, height = 80.dp)
|
||||||
|
.border(1.dp, border, shape)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
dow,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = fg.copy(alpha = 0.85f)
|
||||||
|
)
|
||||||
|
Text(dayNum, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = fg)
|
||||||
|
if (selected) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(fg)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekHeaderStub(state: ScheduleUiState) {
|
||||||
|
val start = state.dayStrip.firstOrNull()
|
||||||
|
val end = state.dayStrip.lastOrNull()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
start != null && end != null -> "Неделя: $start — $end"
|
||||||
|
else -> "Неделя"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScheduleContent(
|
||||||
|
state: ScheduleUiState,
|
||||||
|
onIntent: (ScheduleIntent) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(modifier)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// ✅ неблокирующий индикатор обновления (если уже есть данные)
|
||||||
|
if (state.isLoading && state.raw != null) {
|
||||||
|
item {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ErrorBlock(message = state.error, onRetry = { onIntent(ScheduleIntent.Retry) })
|
||||||
|
}
|
||||||
|
return@LazyColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading && state.raw == null) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
return@LazyColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state.viewMode) {
|
||||||
|
ScheduleViewMode.DAY -> {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = dayTitle(state.selectedDate),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.dayLessons.isEmpty()) {
|
||||||
|
item { EmptyBlock("На выбранный день пар нет.") }
|
||||||
|
} else {
|
||||||
|
items(items = state.dayLessons, key = { it.id }) { lesson ->
|
||||||
|
LessonCard(lesson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleViewMode.WEEK -> {
|
||||||
|
state.weekGrouped.forEach { day ->
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = day.title,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (day.lessons.isEmpty()) {
|
||||||
|
item { EmptyInline("Нет занятий") }
|
||||||
|
} else {
|
||||||
|
items(items = day.lessons, key = { it.id }) { lesson ->
|
||||||
|
LessonCard(lesson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val refreshed = (state.raw as? DataResult.Data)?.refreshedFromNetwork
|
||||||
|
if (refreshed != null) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = if (refreshed) "Источник: NETWORK" else "Источник: CACHE",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LessonCard(lesson: UiLesson) {
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
val borderColor =
|
||||||
|
if (lesson.isNow) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
|
||||||
|
val borderWidth = if (lesson.isNow) 2.dp else 1.dp
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = shape,
|
||||||
|
tonalElevation = if (lesson.isNow) 2.dp else 0.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(borderWidth, borderColor, shape)
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(14.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = lesson.time,
|
||||||
|
fontWeight = if (lesson.isNow) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
|
color = if (lesson.isNow) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (lesson.isNow) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
label = { Text("СЕЙЧАС", fontWeight = FontWeight.Black) },
|
||||||
|
colors = AssistChipDefaults.assistChipColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f),
|
||||||
|
labelColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
LessonBadge(text = lesson.badgeText, kind = lesson.badgeKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = lesson.title,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = lesson.teacher,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = lesson.room,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LessonBadge(text: String, kind: BadgeKind) {
|
||||||
|
val (bg, fg) = when (kind) {
|
||||||
|
BadgeKind.INFO -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) to MaterialTheme.colorScheme.primary
|
||||||
|
BadgeKind.PRIMARY -> MaterialTheme.colorScheme.error.copy(alpha = 0.12f) to MaterialTheme.colorScheme.error
|
||||||
|
BadgeKind.LAB -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.12f) to MaterialTheme.colorScheme.tertiary
|
||||||
|
BadgeKind.MUTED -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(color = bg, contentColor = fg, shape = RoundedCornerShape(8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(14.dp)) {
|
||||||
|
Text("Ошибка", fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(message)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Button(onClick = onRetry) { Text("Повторить") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyBlock(text: String) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.padding(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyInline(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dayTitle(date: String): String {
|
||||||
|
val prefix = if (date == todayString()) "Сегодня" else "День"
|
||||||
|
return "$prefix, $date"
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package ru.fincode.tsudesk.feature.schedule.presentation.screen
|
||||||
|
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.domain.model.LessonEntity
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.domain.model.LessonType
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.dowRuShort
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
object ScheduleUiMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг уроков на выбранный день (dd.MM.yyyy)
|
||||||
|
*/
|
||||||
|
fun mapDayLessons(entity: ScheduleEntity, selectedDate: String): List<UiLesson> {
|
||||||
|
val today = todayString()
|
||||||
|
val nowMinutes = nowMinutes()
|
||||||
|
|
||||||
|
return entity.lessons
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.date == selectedDate }
|
||||||
|
.sortedBy { startMinutesOrMax(it.time) }
|
||||||
|
.map { lesson ->
|
||||||
|
val (badgeText, badgeKind) = badgeFor(lesson)
|
||||||
|
UiLesson(
|
||||||
|
id = stableId(lesson),
|
||||||
|
time = normalizeTime(lesson.time),
|
||||||
|
title = lesson.name,
|
||||||
|
teacher = lesson.teacher,
|
||||||
|
room = lesson.room,
|
||||||
|
badgeText = badgeText,
|
||||||
|
badgeKind = badgeKind,
|
||||||
|
isNow = isNowLesson(
|
||||||
|
lessonDate = lesson.date,
|
||||||
|
lessonTime = lesson.time,
|
||||||
|
today = today,
|
||||||
|
nowMinutes = nowMinutes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группировка на неделю по списку dayStrip (порядок сохраняется)
|
||||||
|
*/
|
||||||
|
fun mapWeekGrouped(entity: ScheduleEntity, dayStrip: List<String>): List<UiWeekDay> {
|
||||||
|
val lessonsByDate = entity.lessons.groupBy { it.date }
|
||||||
|
val today = todayString()
|
||||||
|
val nowMinutes = nowMinutes()
|
||||||
|
|
||||||
|
return dayStrip.map { date ->
|
||||||
|
val lessons = (lessonsByDate[date].orEmpty())
|
||||||
|
.sortedBy { startMinutesOrMax(it.time) }
|
||||||
|
.map { lesson ->
|
||||||
|
val (badgeText, badgeKind) = badgeFor(lesson)
|
||||||
|
UiLesson(
|
||||||
|
id = stableId(lesson),
|
||||||
|
time = normalizeTime(lesson.time),
|
||||||
|
title = lesson.name,
|
||||||
|
teacher = lesson.teacher,
|
||||||
|
room = lesson.room,
|
||||||
|
badgeText = badgeText,
|
||||||
|
badgeKind = badgeKind,
|
||||||
|
isNow = isNowLesson(
|
||||||
|
lessonDate = lesson.date,
|
||||||
|
lessonTime = lesson.time,
|
||||||
|
today = today,
|
||||||
|
nowMinutes = nowMinutes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
UiWeekDay(
|
||||||
|
date = date,
|
||||||
|
title = weekDayTitle(date),
|
||||||
|
lessons = lessons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weekDayTitle(date: String): String {
|
||||||
|
// "Пн, 12.01.2026" (dowRuShort уже без java.time)
|
||||||
|
return "${dowRuShort(date)}, $date"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun badgeFor(lesson: LessonEntity): Pair<String, BadgeKind> {
|
||||||
|
return when (lesson.type) {
|
||||||
|
LessonType.LECTURE -> "Лекция" to BadgeKind.INFO
|
||||||
|
LessonType.PRACTICE -> "Практика" to BadgeKind.PRIMARY
|
||||||
|
LessonType.LAB -> "Лабораторная" to BadgeKind.LAB
|
||||||
|
LessonType.DEFAULT -> {
|
||||||
|
// fallback: если API прислал typeName — укоротим до 1 слова, иначе "Занятие"
|
||||||
|
val text = lesson.typeName.trim().ifBlank { "Занятие" }.let { tn ->
|
||||||
|
tn.split(' ', ',', ';').firstOrNull().orEmpty().ifBlank { "Занятие" }
|
||||||
|
}
|
||||||
|
text to BadgeKind.MUTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стабильный id для LazyColumn key
|
||||||
|
*/
|
||||||
|
private fun stableId(lesson: LessonEntity): String {
|
||||||
|
// date|time|name|room|teacher
|
||||||
|
return buildString {
|
||||||
|
append(lesson.date)
|
||||||
|
append('|')
|
||||||
|
append(lesson.time)
|
||||||
|
append('|')
|
||||||
|
append(lesson.name)
|
||||||
|
append('|')
|
||||||
|
append(lesson.room)
|
||||||
|
append('|')
|
||||||
|
append(lesson.teacher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "08:30 — 10:00" / "08:30-10:00" / "08:30 - 10:00" -> "08:30 — 10:00"
|
||||||
|
*/
|
||||||
|
private fun normalizeTime(raw: String): String {
|
||||||
|
val (s, e) = parseStartEnd(raw) ?: return raw.trim()
|
||||||
|
return "$s — $e"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит начало/конец из строки времени.
|
||||||
|
* Возвращает null если не смогли распознать.
|
||||||
|
*/
|
||||||
|
private fun parseStartEnd(raw: String): Pair<String, String>? {
|
||||||
|
// Достаём 2 времени формата HH:mm
|
||||||
|
val matches = Regex("""\b([01]\d|2[0-3]):[0-5]\d\b""")
|
||||||
|
.findAll(raw)
|
||||||
|
.map { it.value }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
if (matches.size >= 2) return matches[0] to matches[1]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startMinutesOrMax(rawTime: String): Int {
|
||||||
|
val start = parseStartEnd(rawTime)?.first ?: return Int.MAX_VALUE
|
||||||
|
return toMinutes(start) ?: Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toMinutes(hhmm: String): Int? {
|
||||||
|
val parts = hhmm.split(':')
|
||||||
|
if (parts.size != 2) return null
|
||||||
|
val h = parts[0].toIntOrNull() ?: return null
|
||||||
|
val m = parts[1].toIntOrNull() ?: return null
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nowMinutes(): Int {
|
||||||
|
val c = Calendar.getInstance()
|
||||||
|
val h = c.get(Calendar.HOUR_OF_DAY)
|
||||||
|
val m = c.get(Calendar.MINUTE)
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNowLesson(
|
||||||
|
lessonDate: String,
|
||||||
|
lessonTime: String,
|
||||||
|
today: String,
|
||||||
|
nowMinutes: Int
|
||||||
|
): Boolean {
|
||||||
|
if (lessonDate != today) return false
|
||||||
|
val (s, e) = parseStartEnd(lessonTime) ?: return false
|
||||||
|
val start = toMinutes(s) ?: return false
|
||||||
|
val end = toMinutes(e) ?: return false
|
||||||
|
// обычно end > start; если вдруг кривые данные — не подсвечиваем
|
||||||
|
if (end <= start) return false
|
||||||
|
return nowMinutes in start..end
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +1,223 @@
|
|||||||
package ru.fincode.tsudesk.feature.schedule.presentation.screen
|
package ru.fincode.tsudesk.feature.schedule.presentation.screen
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import ru.fincode.tsudesk.core.common.log.logD
|
import ru.fincode.tsudesk.core.common.log.logD
|
||||||
import ru.fincode.tsudesk.core.common.log.logE
|
import ru.fincode.tsudesk.core.common.log.logE
|
||||||
import ru.fincode.tsudesk.core.common.model.DataResult
|
import ru.fincode.tsudesk.core.common.model.DataResult
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
|
||||||
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType
|
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType
|
||||||
import ru.fincode.tsudesk.feature.schedule.domain.usecase.ObserveScheduleUseCase
|
import ru.fincode.tsudesk.feature.schedule.domain.usecase.ObserveScheduleUseCase
|
||||||
import ru.fincode.tsudesk.feature.schedule.presentation.model.ScheduleUiState
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.buildDayStrip
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.toUiMessage
|
||||||
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ScheduleViewModel @Inject constructor(
|
class ScheduleViewModel @Inject constructor(
|
||||||
private val observeScheduleUseCase: ObserveScheduleUseCase
|
private val observeScheduleUseCase: ObserveScheduleUseCase
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val scheduleType = ScheduleType.Group(number = "220631")
|
private val _state = MutableStateFlow(
|
||||||
|
ScheduleUiState(
|
||||||
|
selectedDate = todayString(),
|
||||||
|
dayStrip = buildDayStrip(todayString())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val state: StateFlow<ScheduleUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
val state: StateFlow<ScheduleUiState> = observeScheduleUseCase(scheduleType).map { result ->
|
init {
|
||||||
|
logD("ScheduleViewModel init. selectedDate=${_state.value.selectedDate}")
|
||||||
|
_state
|
||||||
|
.map { it.selectedType }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { type -> logD("Observe schedule: type=$type key=${type.toCacheKey()}") }
|
||||||
|
.flatMapLatest { type -> observeScheduleUseCase(type) }
|
||||||
|
.onEach { reduce(it) }
|
||||||
|
.onCompletion { cause ->
|
||||||
|
// страховка: если поток завершился и loading остался висеть
|
||||||
|
if (_state.value.isLoading) {
|
||||||
|
logD("Observe completed. cause=${cause?.message}. Stop loading.")
|
||||||
|
_state.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { e ->
|
||||||
|
logE("UseCase flow error", e)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Ошибка"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onIntent(intent: ScheduleIntent) {
|
||||||
|
when (intent) {
|
||||||
|
is ScheduleIntent.ChangeGroupInput ->
|
||||||
|
_state.update { it.copy(groupInput = intent.value) }
|
||||||
|
|
||||||
|
is ScheduleIntent.SetGroupMenuExpanded ->
|
||||||
|
_state.update { it.copy(isGroupMenuExpanded = intent.expanded) }
|
||||||
|
|
||||||
|
is ScheduleIntent.SelectRecentGroup -> {
|
||||||
|
logD("SelectRecentGroup: ${intent.number}")
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
groupInput = intent.number,
|
||||||
|
isGroupMenuExpanded = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ScheduleIntent.RemoveRecentGroup -> {
|
||||||
|
logD("RemoveRecentGroup: ${intent.number}")
|
||||||
|
_state.update {
|
||||||
|
it.copy(recentGroups = it.recentGroups.filterNot { g -> g == intent.number })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleIntent.ApplyGroup -> {
|
||||||
|
logD("ApplyGroup clicked")
|
||||||
|
applyGroupAndReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
is ScheduleIntent.SelectDate -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedDate = intent.date,
|
||||||
|
dayStrip = buildDayStrip(intent.date)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
remapIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
is ScheduleIntent.SetViewMode -> {
|
||||||
|
_state.update { it.copy(viewMode = intent.mode) }
|
||||||
|
remapIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleIntent.Retry -> {
|
||||||
|
// Ретрай можно сделать через "переприсвоение" selectedType (триггер distinctUntilChanged не пропустит)
|
||||||
|
// поэтому делаем принудительный рефреш: сбрасываем raw/error + ставим loading,
|
||||||
|
// а реальную перезагрузку должен инициировать usecase репозитория (если он умеет).
|
||||||
|
// Если в usecase нет принудительного refresh — оставляем как UI-retry (сброс ошибки).
|
||||||
|
logD("Retry requested")
|
||||||
|
_state.update { it.copy(error = null) }
|
||||||
|
// Если нужен именно network refresh — лучше добавить отдельный intent/usecase:
|
||||||
|
// observeScheduleUseCase.refresh(selectedType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyGroupAndReload() {
|
||||||
|
val current = _state.value
|
||||||
|
if (current.isLoading) {
|
||||||
|
logD("ApplyGroup ignored: already loading")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val number = current.groupInput.trim()
|
||||||
|
if (number.isEmpty()) {
|
||||||
|
_state.update { it.copy(error = "Введите номер группы") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val type = ScheduleType.Group(number)
|
||||||
|
logD("Start load schedule for group=$number key=${type.toCacheKey()}")
|
||||||
|
|
||||||
|
// При смене группы очищаем данные + обновляем selectedType.
|
||||||
|
// Наблюдение перезапустится автоматически (flatMapLatest по selectedType).
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedType = type,
|
||||||
|
recentGroups = (listOf(number) + it.recentGroups).distinct().take(8),
|
||||||
|
isGroupMenuExpanded = false,
|
||||||
|
error = null,
|
||||||
|
raw = null,
|
||||||
|
isLoading = true, // визуально сразу показываем загрузку
|
||||||
|
dayLessons = emptyList(),
|
||||||
|
weekGrouped = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduce(result: DataResult<ScheduleEntity>) {
|
||||||
when (result) {
|
when (result) {
|
||||||
|
DataResult.Loading -> {
|
||||||
|
logD("DataResult.Loading")
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
is DataResult.Data -> {
|
is DataResult.Data -> {
|
||||||
logD("${if (result.refreshedFromNetwork) "NETWORK" else "CACHE"}: ${result.data}")
|
val source = if (result.refreshedFromNetwork) "NETWORK" else "CACHE"
|
||||||
ScheduleUiState(
|
logD("DataResult.Data received. Source=$source lessons=${result.data.lessons.size}")
|
||||||
isLoading = false,
|
|
||||||
data = result.data,
|
_state.update { it.copy(isLoading = false, error = null, raw = result) }
|
||||||
refreshedFromNetwork = result.refreshedFromNetwork
|
remapWithEntity(result.data)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is DataResult.Error -> {
|
is DataResult.Error -> {
|
||||||
logE("Error loading schedule: ${result.error}")
|
logE("DataResult.Error received. error=${result.error}")
|
||||||
ScheduleUiState(isLoading = false, errorMessage = result.error.toString())
|
|
||||||
|
val cached = result.data
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = result.error.toUiMessage(),
|
||||||
|
raw = result
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached != null) {
|
||||||
|
logD("Using cached data. lessons=${cached.lessons.size}")
|
||||||
|
remapWithEntity(cached)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onStart {
|
}
|
||||||
logD("Start collecting schedule flow")
|
|
||||||
emit(ScheduleUiState(isLoading = true))
|
private fun remapIfPossible() {
|
||||||
}.stateIn(
|
val raw = _state.value.raw
|
||||||
scope = viewModelScope,
|
val entity = when (raw) {
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
is DataResult.Data -> raw.data
|
||||||
initialValue = ScheduleUiState(isLoading = true)
|
is DataResult.Error -> raw.data
|
||||||
)
|
else -> null
|
||||||
}
|
} ?: return
|
||||||
|
|
||||||
|
logD("RemapIfPossible triggered")
|
||||||
|
remapWithEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun remapWithEntity(entity: ScheduleEntity) {
|
||||||
|
val selectedDate = _state.value.selectedDate
|
||||||
|
val strip = _state.value.dayStrip
|
||||||
|
|
||||||
|
val dayLessons = ScheduleUiMapper.mapDayLessons(entity, selectedDate)
|
||||||
|
val weekGrouped = ScheduleUiMapper.mapWeekGrouped(entity, strip)
|
||||||
|
|
||||||
|
logD("Remap complete: dayLessons=${dayLessons.size}, weekDays=${weekGrouped.size}")
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
dayLessons = dayLessons,
|
||||||
|
weekGrouped = weekGrouped
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.fincode.tsudesk.feature.schedule.presentation.util
|
||||||
|
|
||||||
|
import ru.fincode.tsudesk.core.common.model.AppError
|
||||||
|
|
||||||
|
fun AppError.toUiMessage(): String = when (this) {
|
||||||
|
AppError.NoInternet -> "Нет подключения к интернету"
|
||||||
|
AppError.Timeout -> "Превышено время ожидания"
|
||||||
|
AppError.Temporary -> "Не удалось обновить данные. Попробуйте ещё раз"
|
||||||
|
is AppError.Http -> "Ошибка сервера (HTTP ${this.code})"
|
||||||
|
is AppError.Unknown -> this.message ?: "Неизвестная ошибка"
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package ru.fincode.tsudesk.feature.schedule.presentation.util
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private const val API_PATTERN = "dd.MM.yyyy"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает сегодняшнюю дату в формате dd.MM.yyyy
|
||||||
|
*/
|
||||||
|
fun todayString(): String {
|
||||||
|
val sdf = SimpleDateFormat(API_PATTERN, Locale.getDefault())
|
||||||
|
return sdf.format(Calendar.getInstance().time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит список дат (Пн–Сб) для недели,
|
||||||
|
* к которой принадлежит anchor.
|
||||||
|
*/
|
||||||
|
fun buildDayStrip(anchor: String): List<String> {
|
||||||
|
val sdf = SimpleDateFormat(API_PATTERN, Locale.getDefault())
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
|
||||||
|
val parsed = runCatching { sdf.parse(anchor) }.getOrNull()
|
||||||
|
?: return emptyList()
|
||||||
|
|
||||||
|
cal.time = parsed
|
||||||
|
|
||||||
|
// Сдвигаем к понедельнику
|
||||||
|
while (cal.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY) {
|
||||||
|
cal.add(Calendar.DAY_OF_MONTH, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (0..5).map {
|
||||||
|
val out = sdf.format(cal.time)
|
||||||
|
cal.add(Calendar.DAY_OF_MONTH, 1)
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Короткий день недели по строке dd.MM.yyyy
|
||||||
|
* Пример: "12.01.2026" -> "Пн"
|
||||||
|
*/
|
||||||
|
fun dowRuShort(date: String): String {
|
||||||
|
val sdf = SimpleDateFormat(API_PATTERN, Locale.getDefault())
|
||||||
|
val parsed = runCatching { sdf.parse(date) }.getOrNull()
|
||||||
|
?: return "Дн"
|
||||||
|
|
||||||
|
val cal = Calendar.getInstance().apply { time = parsed }
|
||||||
|
|
||||||
|
return when (cal.get(Calendar.DAY_OF_WEEK)) {
|
||||||
|
Calendar.MONDAY -> "Пн"
|
||||||
|
Calendar.TUESDAY -> "Вт"
|
||||||
|
Calendar.WEDNESDAY -> "Ср"
|
||||||
|
Calendar.THURSDAY -> "Чт"
|
||||||
|
Calendar.FRIDAY -> "Пт"
|
||||||
|
Calendar.SATURDAY -> "Сб"
|
||||||
|
Calendar.SUNDAY -> "Вс"
|
||||||
|
else -> "Дн"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли переданная дата сегодняшней.
|
||||||
|
*/
|
||||||
|
fun isToday(date: String): Boolean {
|
||||||
|
return date == todayString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует строку времени "HH:mm" в минуты от начала суток.
|
||||||
|
* Возвращает null если формат некорректный.
|
||||||
|
*/
|
||||||
|
fun hmToMinutes(hm: String): Int? {
|
||||||
|
val parts = hm.split(":")
|
||||||
|
if (parts.size != 2) return null
|
||||||
|
|
||||||
|
val h = parts[0].toIntOrNull() ?: return null
|
||||||
|
val m = parts[1].toIntOrNull() ?: return null
|
||||||
|
|
||||||
|
if (h !in 0..23 || m !in 0..59) return null
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует диапазон времени "08:30 - 10:00"
|
||||||
|
* или "08:30 — 10:00" в пару минут (start, end).
|
||||||
|
*/
|
||||||
|
fun parseTimeRangeToMinutes(range: String): Pair<Int, Int>? {
|
||||||
|
val normalized = range
|
||||||
|
.replace("-", "—")
|
||||||
|
.replace("–", "—")
|
||||||
|
|
||||||
|
val parts = normalized.split("—").map { it.trim() }
|
||||||
|
if (parts.size != 2) return null
|
||||||
|
|
||||||
|
val start = hmToMinutes(parts[0]) ?: return null
|
||||||
|
val end = hmToMinutes(parts[1]) ?: return null
|
||||||
|
|
||||||
|
return start to end
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ class SplashViewModel @Inject constructor(
|
|||||||
val errorMessage = when (val result = getConfigUseCase()) {
|
val errorMessage = when (val result = getConfigUseCase()) {
|
||||||
is DataResult.Data -> null
|
is DataResult.Data -> null
|
||||||
is DataResult.Error -> result.error.toString()
|
is DataResult.Error -> result.error.toString()
|
||||||
|
is DataResult.Loading -> null
|
||||||
}
|
}
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ versionCode = "1"
|
|||||||
agp = "8.12.0"
|
agp = "8.12.0"
|
||||||
kotlin = "1.9.24"
|
kotlin = "1.9.24"
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
kotlinCompilerExtension = "1.5.14"
|
|
||||||
serilization = "1.6.3"
|
coroutines = "1.8.1"
|
||||||
|
serialization = "1.6.3"
|
||||||
kotlinxImmutable = "0.3.8"
|
kotlinxImmutable = "0.3.8"
|
||||||
ksp = "1.9.24-1.0.20"
|
|
||||||
|
kotlinCompilerExtension = "1.5.14"
|
||||||
|
|
||||||
coreKtx = "1.10.1"
|
coreKtx = "1.10.1"
|
||||||
appcompat = "1.6.1"
|
appcompat = "1.6.1"
|
||||||
@@ -22,57 +24,64 @@ compose-bom = "2024.10.00"
|
|||||||
hilt-nav-compose = "1.2.0"
|
hilt-nav-compose = "1.2.0"
|
||||||
navigation = "2.8.5"
|
navigation = "2.8.5"
|
||||||
paging = "3.3.2"
|
paging = "3.3.2"
|
||||||
|
lifecycle = "2.7.0"
|
||||||
|
|
||||||
hilt = "2.50"
|
hilt = "2.50"
|
||||||
retrofit = "2.11.0"
|
retrofit = "2.11.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
|
|
||||||
moshi = "1.15.1"
|
moshi = "1.15.1"
|
||||||
|
jsoup = "1.17.2"
|
||||||
room = "2.6.1"
|
room = "2.6.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.3.0"
|
||||||
|
espressoCore = "3.7.0"
|
||||||
|
materialVersion = "1.13.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Android
|
# KotlinX
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serilization" }
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
# UI: AndroidX, Jetpack Compose, etc
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
|
||||||
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
|
||||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
|
||||||
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
|
|
||||||
androidx-navigation-common = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigation" }
|
|
||||||
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutable" }
|
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutable" }
|
||||||
# Compose UI
|
# AndroidX
|
||||||
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||||
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
||||||
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||||
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-nav-compose" }
|
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
# Compose (BOM)
|
||||||
compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
|
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
# Network: okhhtp3+retrofit2
|
compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||||
|
compose-runtime = { module = "androidx.compose.runtime:runtime" }
|
||||||
|
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
# Navigation / Hilt
|
||||||
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||||
|
androidx-navigation-common = { module = "androidx.navigation:navigation-common-ktx", version.ref = "navigation" }
|
||||||
|
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-nav-compose" }
|
||||||
|
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||||
|
# Network
|
||||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||||
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||||
# Network-parse: Moshi+Gson
|
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
|
||||||
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
|
retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
||||||
moshiKotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
|
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||||
retrofitMoshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
|
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
|
||||||
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||||
# DI: Hilt
|
# Room
|
||||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||||
# DB: Room
|
# Debug
|
||||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
material = { group = "com.google.android.material", name = "material", version.ref = "materialVersion" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
android-library = { id = "com.android.library", 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-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
Reference in New Issue
Block a user