impl ui logic

This commit is contained in:
Shcherbatykh Oleg
2026-02-24 15:19:20 +03:00
parent 97c9091038
commit cff73f0f35
19 changed files with 696 additions and 397 deletions

View File

@@ -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)
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -13,6 +13,5 @@ sealed interface DataResult<out T> {
val cause: Throwable? = null val cause: Throwable? = null
) : DataResult<T> ) : DataResult<T>
/** Состояние загрузки */
data object Loading : DataResult<Nothing> data object Loading : DataResult<Nothing>
} }

View File

@@ -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)
} }

View File

@@ -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) {
}
}
}

View File

@@ -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)
} }

View File

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

View File

@@ -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)
} }

View File

@@ -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,41 +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 kapt { correctErrorTypes = true }
}
dependencies { dependencies {
implementation(libs.androidx.appcompat) // Kotlin
implementation(libs.material)
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)
// Compose // 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)
} }

View File

@@ -1,11 +1,9 @@
package ru.fincode.tsudesk.feature.schedule.data package ru.fincode.tsudesk.feature.schedule.data
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import ru.fincode.tsudesk.core.common.model.DataResult import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.core.network.model.NetworkError
import ru.fincode.tsudesk.core.network.model.NetworkResult import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.core.network.model.map import ru.fincode.tsudesk.core.network.model.map
import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleLocalDataSource import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleLocalDataSource
@@ -24,21 +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)
} }
emit(DataResult.Loading) val networkResult = loadScheduleDto(type).map(mapper::invoke)
val networkResult = loadScheduleWithRetry(type).map(mapper::invoke)
if (networkResult is NetworkResult.Error) { if (networkResult is NetworkResult.Error) {
// Если кеш уже был показан — не роняем UI ошибкой
if (cached != null) return@flow if (cached != null) return@flow
emit(DataResult.Error(networkResult.error.toAppError())) emit(DataResult.Error(networkResult.error.toAppError()))
return@flow return@flow
} }
@@ -52,25 +47,6 @@ class ScheduleRepositoryImpl @Inject constructor(
} }
} }
private suspend fun loadScheduleWithRetry(
type: ScheduleType
): NetworkResult<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto> {
var last: NetworkResult<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto>
repeat(3) { attempt ->
last = loadScheduleDto(type)
if (last is NetworkResult.Success) return last
val err = (last as NetworkResult.Error).error
if (!err.isRetryable() || attempt == 2) return last
delay(300L * (attempt + 1)) // 300, 600
}
return loadScheduleDto(type)
}
private suspend fun loadScheduleDto( private suspend fun loadScheduleDto(
type: ScheduleType type: ScheduleType
): NetworkResult<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto> = ): NetworkResult<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto> =
@@ -79,10 +55,7 @@ class ScheduleRepositoryImpl @Inject constructor(
is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name) is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name)
} }
private fun NetworkError.isRetryable(): Boolean =
this is NetworkError.Temporary || this is NetworkError.Timeout
private companion object { private companion object {
private const val CACHE_TTL_MS: Long = 1 // оставил как у тебя private const val CACHE_TTL_MS: Long = 3L * 24L * 60L * 60L * 1000L // 3 дня
} }
} }

View File

@@ -1,124 +0,0 @@
package ru.fincode.tsudesk.feature.schedule.presentation
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 java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.UUID
object ScheduleUiMapper {
private const val API_PATTERN = "dd.MM.yyyy"
private val sdf = SimpleDateFormat(API_PATTERN, Locale.getDefault())
fun mapDayLessons(entity: ScheduleEntity, selectedDate: String): List<UiLesson> {
val now = Calendar.getInstance()
val nowDate = sdf.format(now.time)
return entity.lessons
.asSequence()
.filter { it.date == selectedDate }
.sortedBy { parseStartMinutes(it.time) ?: Int.MAX_VALUE }
.map { it.toUi(isNow = isNowLesson(it, nowDate, now)) }
.toList()
}
fun mapWeekGrouped(entity: ScheduleEntity, weekDates: List<String>): List<UiWeekDay> {
val now = Calendar.getInstance()
val nowDate = sdf.format(now.time)
return weekDates.map { date ->
val lessons = entity.lessons
.asSequence()
.filter { it.date == date }
.sortedBy { parseStartMinutes(it.time) ?: Int.MAX_VALUE }
.map { it.toUi(isNow = isNowLesson(it, nowDate, now)) }
.toList()
UiWeekDay(
date = date,
title = "${dowRu(date)}, $date",
lessons = lessons
)
}
}
private fun LessonEntity.toUi(isNow: Boolean): UiLesson {
val (badgeText, badgeKind) = when (type) {
LessonType.LECTURE -> "Лекция" to BadgeKind.INFO
LessonType.PRACTICE -> "Практика" to BadgeKind.PRIMARY
LessonType.LAB -> "Лабораторная" to BadgeKind.LAB
LessonType.DEFAULT -> (typeName.ifBlank { "Занятие" }) to BadgeKind.MUTED
}
return UiLesson(
id = stableId(this),
time = normalizeDash(time),
title = name,
teacher = teacher,
room = room,
badgeText = badgeText,
badgeKind = badgeKind,
isNow = isNow
)
}
private fun isNowLesson(lesson: LessonEntity, nowDate: String, now: Calendar): Boolean {
if (lesson.date != nowDate) return false
val (startMin, endMin) = parseRangeMinutes(lesson.time) ?: return false
val curMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
return curMin in startMin..endMin
}
private fun parseRangeMinutes(time: String): Pair<Int, Int>? {
val cleaned = normalizeDash(time)
val parts = cleaned.split("").map { it.trim() }
if (parts.size != 2) return null
val s = parseHmToMinutes(parts[0]) ?: return null
val e = parseHmToMinutes(parts[1]) ?: return null
return s to e
}
private fun parseStartMinutes(time: String): Int? {
val cleaned = normalizeDash(time)
val parts = cleaned.split("").map { it.trim() }
if (parts.isEmpty()) return null
return parseHmToMinutes(parts[0])
}
private fun parseHmToMinutes(hm: String): Int? {
// "08:30"
val p = hm.split(":")
if (p.size != 2) return null
val h = p[0].toIntOrNull() ?: return null
val m = p[1].toIntOrNull() ?: return null
if (h !in 0..23 || m !in 0..59) return null
return h * 60 + m
}
private fun normalizeDash(s: String): String =
s.replace("-", "").replace("", "")
private fun stableId(l: LessonEntity): String {
val key = "${l.date}|${l.time}|${l.name}|${l.teacher}|${l.room}|${l.type}"
return UUID.nameUUIDFromBytes(key.toByteArray()).toString()
}
private fun dowRu(date: String): String {
// вычислим день недели через Calendar
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 -> "Дн"
}
}
}

View File

@@ -1,11 +0,0 @@
package ru.fincode.tsudesk.feature.schedule.presentation
import ru.fincode.tsudesk.core.common.model.AppError
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
data class ScheduleUiState(
val selectedGroup: String = "",
val isLoading: Boolean = false,
val schedule: ScheduleEntity? = null,
val error: AppError? = null
)

View File

@@ -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>,
)

View File

@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.* 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.draw.clip import androidx.compose.ui.draw.clip
@@ -105,7 +106,7 @@ private fun ScheduleHeader(
onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) }, onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) },
onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) }, onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) },
// ✅ ИЗМЕНЕНИЕ #1: выбор из dropdown сразу запускает загрузку // выбор из списка -> сразу загрузка
onSelectRecent = { group -> onSelectRecent = { group ->
onIntent(ScheduleIntent.SelectRecentGroup(group)) onIntent(ScheduleIntent.SelectRecentGroup(group))
onIntent(ScheduleIntent.ApplyGroup) onIntent(ScheduleIntent.ApplyGroup)
@@ -181,7 +182,6 @@ private fun GroupSelector(
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
Column(modifier) { Column(modifier) {
Box {
OutlinedTextField( OutlinedTextField(
value = groupInput, value = groupInput,
onValueChange = onValueChange, onValueChange = onValueChange,
@@ -189,8 +189,6 @@ private fun GroupSelector(
singleLine = true, singleLine = true,
enabled = true, enabled = true,
label = { Text("Группа") }, label = { Text("Группа") },
// ✅ ИЗМЕНЕНИЕ #2: Done/Enter сразу запускает ApplyGroup
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onDone = { onDone = {
@@ -199,7 +197,6 @@ private fun GroupSelector(
if (groupInput.isNotBlank()) onApply() if (groupInput.isNotBlank()) onApply()
} }
), ),
trailingIcon = { trailingIcon = {
IconButton(onClick = { onExpanded(!expanded) }) { IconButton(onClick = { onExpanded(!expanded) }) {
Icon( Icon(
@@ -220,6 +217,7 @@ private fun GroupSelector(
) )
) )
// ✅ Меню РЕАЛЬНО под полем, не перекрывает, фокус в поле работает
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { onExpanded(false) }, onDismissRequest = { onExpanded(false) },
@@ -239,16 +237,14 @@ private fun GroupSelector(
onExpanded(false) onExpanded(false)
}, },
text = { text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = num, text = num,
modifier = Modifier.weight(1f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
},
// ✅ trailingIcon — крестик НЕ вызывает onClick элемента меню
trailingIcon = {
IconButton( IconButton(
onClick = { onRemoveRecent(num) } onClick = { onRemoveRecent(num) }
) { ) {
@@ -258,12 +254,10 @@ private fun GroupSelector(
) )
} }
} }
}
) )
} }
} }
} }
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -414,6 +408,17 @@ private fun ScheduleContent(
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.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) { if (state.error != null) {
item { item {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))

View File

@@ -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
}
}

View File

@@ -1,73 +1,221 @@
package ru.fincode.tsudesk.feature.schedule.presentation package ru.fincode.tsudesk.feature.schedule.presentation.screen
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.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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.onCompletion
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.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.repository.ScheduleRepository import ru.fincode.tsudesk.feature.schedule.domain.usecase.ObserveScheduleUseCase
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
@HiltViewModel @HiltViewModel
class ScheduleViewModel @Inject constructor( class ScheduleViewModel @Inject constructor(
private val repository: ScheduleRepository private val observeScheduleUseCase: ObserveScheduleUseCase
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ScheduleUiState()) private val _state = MutableStateFlow(
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow() ScheduleUiState(
selectedDate = todayString(),
private var loadJob: Job? = null dayStrip = buildDayStrip(todayString())
fun onAction(action: ScheduleAction) {
when (action) {
is ScheduleAction.GroupSelected -> {
_uiState.value = _uiState.value.copy(
selectedGroup = action.group,
error = null
) )
startLoad(action.group) // ✅ автозагрузка )
val state: StateFlow<ScheduleUiState> = _state.asStateFlow()
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)
} }
ScheduleAction.ApplyGroupClicked -> { fun onIntent(intent: ScheduleIntent) {
val group = _uiState.value.selectedGroup when (intent) {
if (group.isNotBlank()) startLoad(group) 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 startLoad(group: String) { private fun applyGroupAndReload() {
loadJob?.cancel() val current = _state.value
loadJob = viewModelScope.launch { if (current.isLoading) {
repository.observeSchedule(ScheduleType.Group(group)).collect { result -> 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) {
is DataResult.Loading -> { DataResult.Loading -> {
_uiState.value = _uiState.value.copy( logD("DataResult.Loading")
isLoading = true, _state.update { it.copy(isLoading = true, error = null) }
error = null
)
} }
is DataResult.Data -> { is DataResult.Data -> {
_uiState.value = _uiState.value.copy( val source = if (result.refreshedFromNetwork) "NETWORK" else "CACHE"
isLoading = false, logD("DataResult.Data received. Source=$source lessons=${result.data.lessons.size}")
schedule = result.data,
error = null _state.update { it.copy(isLoading = false, error = null, raw = result) }
) remapWithEntity(result.data)
} }
is DataResult.Error -> { is DataResult.Error -> {
_uiState.value = _uiState.value.copy( logE("DataResult.Error received. error=${result.error}")
val cached = result.data
_state.update {
it.copy(
isLoading = false, isLoading = false,
error = result.error error = result.error.toUiMessage(),
raw = result
)
}
if (cached != null) {
logD("Using cached data. lessons=${cached.lessons.size}")
remapWithEntity(cached)
}
}
}
}
private fun remapIfPossible() {
val raw = _state.value.raw
val entity = when (raw) {
is DataResult.Data -> raw.data
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
) )
} }
} }
}
}
}
} }

View File

@@ -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(

View File

@@ -8,10 +8,13 @@ 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"
kotlinCompilerExtension = "1.5.14"
coreKtx = "1.10.1" coreKtx = "1.10.1"
appcompat = "1.6.1" appcompat = "1.6.1"
material = "1.10.0" material = "1.10.0"
@@ -20,60 +23,70 @@ constraintlayout = "2.1.4"
compose-bom = "2024.10.00" 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"
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"
lifecycle = "2.7.0"
coroutines = "1.8.1"
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-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
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" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
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
compose-ui = { group = "androidx.compose.ui", name = "ui" } # AndroidX
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-nav-compose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-navigation-common = { group = "androidx.navigation", name = "navigation-common-ktx", 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" }
# 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
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } # Debug
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
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" }
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" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }