impl ui logic
This commit is contained in:
@@ -12,10 +12,8 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.fincode.tsudesk"
|
||||
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
|
||||
versionCode = libs.versions.versionCode.get().toInt()
|
||||
versionName = libs.versions.versionName.get()
|
||||
}
|
||||
@@ -29,6 +27,7 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
|
||||
compileOptions {
|
||||
sourceCompatibility = jvm
|
||||
@@ -37,36 +36,37 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = jvm.toString()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
|
||||
dependencies {
|
||||
// Compose
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
|
||||
// Navigation Compose
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.navigation.common)
|
||||
|
||||
kapt(libs.hilt.compiler)
|
||||
// DI: Hilt
|
||||
implementation(libs.hilt.android)
|
||||
kapt(libs.hilt.compiler)
|
||||
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.retrofit)
|
||||
|
||||
// Modules
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.config)
|
||||
implementation(projects.core.navigation)
|
||||
@@ -76,4 +76,7 @@ dependencies {
|
||||
implementation(projects.feature.schedule)
|
||||
implementation(projects.feature.progress)
|
||||
implementation(projects.feature.news)
|
||||
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
@@ -1,16 +1,3 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<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>
|
||||
<style name="Theme.TSUDesk" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
@@ -1,16 +1,3 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<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>
|
||||
<style name="Theme.TSUDesk" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -13,6 +13,5 @@ sealed interface DataResult<out T> {
|
||||
val cause: Throwable? = null
|
||||
) : DataResult<T>
|
||||
|
||||
/** Состояние загрузки */
|
||||
data object Loading : DataResult<Nothing>
|
||||
}
|
||||
@@ -45,11 +45,11 @@ dependencies {
|
||||
api(libs.okhttp)
|
||||
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.converter.gson)
|
||||
implementation(libs.retrofit.gson)
|
||||
|
||||
api(libs.moshi)
|
||||
api(libs.moshiKotlin)
|
||||
api(libs.retrofitMoshi)
|
||||
api(libs.moshi.kotlin)
|
||||
api(libs.retrofit.moshi)
|
||||
|
||||
implementation(projects.core.common)
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,6 @@ android {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
}
|
||||
@@ -31,7 +31,6 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.core.ktx)
|
||||
}
|
||||
@@ -31,7 +31,6 @@ android {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
}
|
||||
@@ -19,7 +19,8 @@ android {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -29,41 +30,45 @@ android {
|
||||
sourceCompatibility = jvm
|
||||
targetCompatibility = jvm
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = jvm.toString()
|
||||
}
|
||||
kotlinOptions { jvmTarget = jvm.toString() }
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
|
||||
}
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
kapt { correctErrorTypes = true }
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
// Kotlin
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
||||
// Compose
|
||||
// Compose UI
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
|
||||
// Navigation Compose
|
||||
// Navigation (Compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.navigation.common)
|
||||
|
||||
kapt(libs.hilt.compiler)
|
||||
// DI
|
||||
implementation(libs.hilt.android)
|
||||
kapt(libs.hilt.compiler)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
implementation(projects.core.network)
|
||||
implementation(projects.core.database)
|
||||
// Core modules
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.config)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.navigation)
|
||||
|
||||
// Data modules used by feature (если feature реально ходит в сеть/бд)
|
||||
implementation(projects.core.network)
|
||||
implementation(projects.core.database)
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package ru.fincode.tsudesk.feature.schedule.data
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
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.map
|
||||
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 {
|
||||
val scheduleKey = type.toCacheKey()
|
||||
|
||||
val cached = local.observeSchedule(scheduleKey).first()
|
||||
|
||||
if (cached != null) {
|
||||
emit(DataResult.Data(cached, refreshedFromNetwork = false))
|
||||
if ((System.currentTimeMillis() - cached.timestamp) < CACHE_TTL_MS) return@flow
|
||||
} else {
|
||||
emit(DataResult.Loading)
|
||||
}
|
||||
|
||||
emit(DataResult.Loading)
|
||||
|
||||
val networkResult = loadScheduleWithRetry(type).map(mapper::invoke)
|
||||
|
||||
val networkResult = loadScheduleDto(type).map(mapper::invoke)
|
||||
if (networkResult is NetworkResult.Error) {
|
||||
// Если кеш уже был показан — не роняем UI ошибкой
|
||||
if (cached != null) return@flow
|
||||
|
||||
emit(DataResult.Error(networkResult.error.toAppError()))
|
||||
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(
|
||||
type: ScheduleType
|
||||
): 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)
|
||||
}
|
||||
|
||||
private fun NetworkError.isRetryable(): Boolean =
|
||||
this is NetworkError.Temporary || this is NetworkError.Timeout
|
||||
|
||||
private companion object {
|
||||
private const val CACHE_TTL_MS: Long = 1 // оставил как у тебя
|
||||
private const val CACHE_TTL_MS: Long = 3L * 24L * 60L * 60L * 1000L // 3 дня
|
||||
}
|
||||
}
|
||||
@@ -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 -> "Дн"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -16,6 +16,7 @@ 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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -105,7 +106,7 @@ private fun ScheduleHeader(
|
||||
onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) },
|
||||
onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) },
|
||||
|
||||
// ✅ ИЗМЕНЕНИЕ #1: выбор из dropdown сразу запускает загрузку
|
||||
// выбор из списка -> сразу загрузка
|
||||
onSelectRecent = { group ->
|
||||
onIntent(ScheduleIntent.SelectRecentGroup(group))
|
||||
onIntent(ScheduleIntent.ApplyGroup)
|
||||
@@ -181,7 +182,6 @@ private fun GroupSelector(
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
Column(modifier) {
|
||||
Box {
|
||||
OutlinedTextField(
|
||||
value = groupInput,
|
||||
onValueChange = onValueChange,
|
||||
@@ -189,8 +189,6 @@ private fun GroupSelector(
|
||||
singleLine = true,
|
||||
enabled = true,
|
||||
label = { Text("Группа") },
|
||||
|
||||
// ✅ ИЗМЕНЕНИЕ #2: Done/Enter сразу запускает ApplyGroup
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
@@ -199,7 +197,6 @@ private fun GroupSelector(
|
||||
if (groupInput.isNotBlank()) onApply()
|
||||
}
|
||||
),
|
||||
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { onExpanded(!expanded) }) {
|
||||
Icon(
|
||||
@@ -220,6 +217,7 @@ private fun GroupSelector(
|
||||
)
|
||||
)
|
||||
|
||||
// ✅ Меню РЕАЛЬНО под полем, не перекрывает, фокус в поле работает
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { onExpanded(false) },
|
||||
@@ -239,16 +237,14 @@ private fun GroupSelector(
|
||||
onExpanded(false)
|
||||
},
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = num,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
// ✅ trailingIcon — крестик НЕ вызывает onClick элемента меню
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { onRemoveRecent(num) }
|
||||
) {
|
||||
@@ -258,12 +254,10 @@ private fun GroupSelector(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -414,6 +408,17 @@ private fun ScheduleContent(
|
||||
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))
|
||||
|
||||
@@ -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,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.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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.feature.schedule.domain.model.ScheduleEntity
|
||||
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
|
||||
|
||||
@HiltViewModel
|
||||
class ScheduleViewModel @Inject constructor(
|
||||
private val repository: ScheduleRepository
|
||||
private val observeScheduleUseCase: ObserveScheduleUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
|
||||
fun onAction(action: ScheduleAction) {
|
||||
when (action) {
|
||||
is ScheduleAction.GroupSelected -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedGroup = action.group,
|
||||
error = null
|
||||
private val _state = MutableStateFlow(
|
||||
ScheduleUiState(
|
||||
selectedDate = todayString(),
|
||||
dayStrip = buildDayStrip(todayString())
|
||||
)
|
||||
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 -> {
|
||||
val group = _uiState.value.selectedGroup
|
||||
if (group.isNotBlank()) startLoad(group)
|
||||
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 startLoad(group: String) {
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
repository.observeSchedule(ScheduleType.Group(group)).collect { result ->
|
||||
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) {
|
||||
is DataResult.Loading -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = true,
|
||||
error = null
|
||||
)
|
||||
DataResult.Loading -> {
|
||||
logD("DataResult.Loading")
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
}
|
||||
|
||||
is DataResult.Data -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
schedule = result.data,
|
||||
error = null
|
||||
)
|
||||
val source = if (result.refreshedFromNetwork) "NETWORK" else "CACHE"
|
||||
logD("DataResult.Data received. Source=$source lessons=${result.data.lessons.size}")
|
||||
|
||||
_state.update { it.copy(isLoading = false, error = null, raw = result) }
|
||||
remapWithEntity(result.data)
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class SplashViewModel @Inject constructor(
|
||||
val errorMessage = when (val result = getConfigUseCase()) {
|
||||
is DataResult.Data -> null
|
||||
is DataResult.Error -> result.error.toString()
|
||||
is DataResult.Loading -> null
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
||||
@@ -8,10 +8,13 @@ versionCode = "1"
|
||||
agp = "8.12.0"
|
||||
kotlin = "1.9.24"
|
||||
jvmTarget = "17"
|
||||
kotlinCompilerExtension = "1.5.14"
|
||||
serilization = "1.6.3"
|
||||
|
||||
coroutines = "1.8.1"
|
||||
serialization = "1.6.3"
|
||||
kotlinxImmutable = "0.3.8"
|
||||
|
||||
kotlinCompilerExtension = "1.5.14"
|
||||
|
||||
coreKtx = "1.10.1"
|
||||
appcompat = "1.6.1"
|
||||
material = "1.10.0"
|
||||
@@ -20,60 +23,70 @@ constraintlayout = "2.1.4"
|
||||
compose-bom = "2024.10.00"
|
||||
hilt-nav-compose = "1.2.0"
|
||||
navigation = "2.8.5"
|
||||
lifecycle = "2.7.0"
|
||||
|
||||
hilt = "2.50"
|
||||
retrofit = "2.11.0"
|
||||
okhttp = "4.12.0"
|
||||
|
||||
moshi = "1.15.1"
|
||||
lifecycle = "2.7.0"
|
||||
coroutines = "1.8.1"
|
||||
|
||||
room = "2.6.1"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
materialVersion = "1.13.0"
|
||||
|
||||
[libraries]
|
||||
# Android
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serilization" }
|
||||
# UI: AndroidX, Jetpack Compose
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
# KotlinX
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutable" }
|
||||
# Compose UI
|
||||
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-nav-compose" }
|
||||
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-runtime = { group = "androidx.compose.runtime", name = "runtime" }
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||
# Network: okhhtp3+retrofit2
|
||||
|
||||
# AndroidX
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||
|
||||
# Compose (BOM)
|
||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
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-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||
# Network-parse: Moshi+Gson
|
||||
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
|
||||
moshiKotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
|
||||
retrofitMoshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
|
||||
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
||||
# DI: Hilt
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
# DB: Room
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
|
||||
retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
||||
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
|
||||
|
||||
# Room
|
||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
|
||||
# Debug
|
||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
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]
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||
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