Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5140d751 | ||
|
|
65bb6cc7db | ||
|
|
99e05e8232 | ||
|
|
c4ecc43597 | ||
|
|
9a96bd3dac | ||
|
|
c4345f9dbe | ||
|
|
4797811097 | ||
|
|
2dfef426a7 | ||
|
|
97c19647cc |
@@ -29,19 +29,18 @@ jobs:
|
|||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
||||||
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
||||||
# JDK качаем из архива Adoptium напрямую: образ раннера — Debian bullseye,
|
|
||||||
# его репозитории уже в архиве, apt-установка openjdk падает с 404.
|
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
apt-get update
|
cd /tmp
|
||||||
apt-get install -y --no-install-recommends wget unzip
|
wget -q https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz
|
||||||
mkdir -p /opt/jdk
|
tar -xzf OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz -C /opt
|
||||||
wget -qO /tmp/jdk17.tar.gz \
|
mv /opt/jdk-17.0.12+7 /opt/java17
|
||||||
"https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
|
export JAVA_HOME=/opt/java17
|
||||||
tar -xzf /tmp/jdk17.tar.gz -C /opt/jdk --strip-components=1
|
export PATH=$JAVA_HOME/bin:$PATH
|
||||||
echo "JAVA_HOME=/opt/jdk" >> "$GITHUB_ENV"
|
echo "JAVA_HOME=/opt/java17" >> $GITHUB_ENV
|
||||||
echo "/opt/jdk/bin" >> "$GITHUB_PATH"
|
echo "$JAVA_HOME/bin" >> $GITHUB_PATH
|
||||||
/opt/jdk/bin/java -version
|
java -version
|
||||||
|
javac -version
|
||||||
|
|
||||||
- name: Install Android SDK
|
- name: Install Android SDK
|
||||||
# Устанавливаем command-line tools и нужные компоненты SDK,
|
# Устанавливаем command-line tools и нужные компоненты SDK,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ android {
|
|||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
|
unitTests.all { it.maxHeapSize = "16m" }
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ fun AppRoot() {
|
|||||||
val container = app.container
|
val container = app.container
|
||||||
|
|
||||||
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
|
||||||
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
||||||
|
|
||||||
val state by scheduleViewModel.uiState.collectAsState()
|
val state by scheduleViewModel.uiState.collectAsState()
|
||||||
@@ -53,6 +52,7 @@ fun AppRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.noGroup) {
|
if (state.noGroup) {
|
||||||
|
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||||
GroupPickerScreen(
|
GroupPickerScreen(
|
||||||
viewModel = groupPickerViewModel,
|
viewModel = groupPickerViewModel,
|
||||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ package agzam.vyatsu.data.remote
|
|||||||
import agzam.vyatsu.domain.model.DateRange
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
import agzam.vyatsu.domain.model.Group
|
import agzam.vyatsu.domain.model.Group
|
||||||
import agzam.vyatsu.domain.model.Season
|
import agzam.vyatsu.domain.model.Season
|
||||||
import android.util.Log
|
import java.io.InputStream
|
||||||
import org.jsoup.Jsoup
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
||||||
@@ -14,6 +13,13 @@ object GroupsParser {
|
|||||||
|
|
||||||
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
||||||
|
|
||||||
|
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
||||||
|
private val parenthesized = Regex("""\(.*?\)\s?""")
|
||||||
|
|
||||||
|
private const val CHUNK_SIZE = 8192
|
||||||
|
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
||||||
|
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
|
||||||
|
|
||||||
private val shorthands = mapOf(
|
private val shorthands = mapOf(
|
||||||
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||||
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||||
@@ -33,41 +39,118 @@ object GroupsParser {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Извлекает список групп с HTML страницы
|
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
||||||
|
* разбирает его на лету, не материализуя весь HTML в память.
|
||||||
|
*
|
||||||
|
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
|
||||||
|
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
|
||||||
|
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
|
||||||
*/
|
*/
|
||||||
fun parseGroups(html: String): List<Group> {
|
fun parseStream(input: InputStream): ParsedGroups {
|
||||||
val doc = Jsoup.parse(html)
|
val reader = input.bufferedReader(Charsets.UTF_8)
|
||||||
val container = doc.selectFirst("div.column-center_rasp") ?: return emptyList()
|
val buf = CharArray(CHUNK_SIZE)
|
||||||
val tables = container.select("table[style~=border: none !important]")
|
val pending = StringBuilder(CHUNK_SIZE * 2)
|
||||||
|
val groups = ArrayList<Group>()
|
||||||
|
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||||
|
var faculty = ""
|
||||||
|
|
||||||
return tables.flatMap { table ->
|
fun addRanges(text: String) {
|
||||||
table.select("> tbody > tr, > tr").flatMap { row ->
|
for (m in groupPattern.findAll(text)) {
|
||||||
Log.v("ROW", row.html());
|
val groupId = m.groupValues[1]
|
||||||
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) //
|
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||||
|
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||||
row.select("div.grpPeriod").map { grp ->
|
ranges.getOrPut(groupId) {
|
||||||
Group(
|
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
|
||||||
id = grp.attr("data-grp_period_id").dropLast(1),
|
}.getOrPut(season) { ArrayList() }.add(range)
|
||||||
name = removeParenthesized(grp.text()),
|
|
||||||
faculty = faculty
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val n = reader.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
pending.append(buf, 0, n)
|
||||||
|
|
||||||
|
var pos = 0
|
||||||
|
while (pos < pending.length) {
|
||||||
|
val divAt = pending.indexOf("<div", pos)
|
||||||
|
val aAt = pending.indexOf("<a", pos)
|
||||||
|
val tagStart = when {
|
||||||
|
divAt < 0 -> aAt
|
||||||
|
aAt < 0 -> divAt
|
||||||
|
else -> minOf(divAt, aAt)
|
||||||
|
}
|
||||||
|
if (tagStart < 0) {
|
||||||
|
// хвостовой текст без div-тегов — обычный текст страницы
|
||||||
|
pos = pending.length
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (tagStart > pos) {
|
||||||
|
addRanges(pending.substring(pos, tagStart))
|
||||||
|
}
|
||||||
|
val tagEnd = findTagEnd(pending, tagStart)
|
||||||
|
if (tagEnd < 0) break // тег не дочитан — ждём следующий чанк
|
||||||
|
val tagText = pending.substring(tagStart, tagEnd + 1)
|
||||||
|
|
||||||
|
val isDiv = tagText.startsWith("<div")
|
||||||
|
val isFakName = isDiv && tagText.contains("class=\"fak_name\"")
|
||||||
|
val isGrpPeriod = isDiv && tagText.contains("class=\"grpPeriod\"")
|
||||||
|
if (isFakName || isGrpPeriod) {
|
||||||
|
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
|
||||||
|
if (closeDiv < 0) break // содержимое не дочитано — ждём следующий чанк
|
||||||
|
addRanges(tagText)
|
||||||
|
val content = pending.substring(tagEnd + 1, closeDiv)
|
||||||
|
if (isFakName) {
|
||||||
|
val name = content.trim()
|
||||||
|
if (name.isNotEmpty()) faculty = shortname(name)
|
||||||
|
} else {
|
||||||
|
val m = grpIdPattern.find(tagText)
|
||||||
|
if (m != null) {
|
||||||
|
groups += Group(
|
||||||
|
id = m.groupValues[1].dropLast(1),
|
||||||
|
name = removeParenthesized(content),
|
||||||
|
faculty = faculty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = closeDiv + 6
|
||||||
|
} else {
|
||||||
|
addRanges(tagText)
|
||||||
|
pos = tagEnd + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos > MAX_SLICE) {
|
||||||
|
pending.delete(0, pos)
|
||||||
|
pos = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos > 0 && pos <= pending.length) {
|
||||||
|
pending.delete(0, pos)
|
||||||
|
}
|
||||||
|
if (pending.length > MAX_PENDING) {
|
||||||
|
// на странице нет настолько длинных конструкций; страховка от аномалий
|
||||||
|
pending.delete(0, pending.length - MAX_SLICE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ParsedGroups(groups, ranges)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseDateRanges(html: String): Map<String, Map<Season, List<DateRange>>> {
|
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
|
||||||
val result = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
private fun findTagEnd(s: CharSequence, start: Int): Int {
|
||||||
groupPattern.findAll(html).forEach { m ->
|
var i = start
|
||||||
val groupId = m.groupValues[1]
|
var quote = 0.toChar()
|
||||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
while (i < s.length) {
|
||||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
val c = s[i]
|
||||||
result
|
if (quote != 0.toChar()) {
|
||||||
.getOrPut(groupId) { hashMapOf(Season.AUTUMN to mutableListOf(), Season.SPRING to mutableListOf()) }
|
if (c == quote) quote = 0.toChar()
|
||||||
.getOrPut(season) { mutableListOf() }
|
} else {
|
||||||
.add(range)
|
when (c) {
|
||||||
|
'"', '\'' -> quote = c
|
||||||
|
'>' -> return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
return result
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shortname(name: String): String {
|
private fun shortname(name: String): String {
|
||||||
@@ -76,5 +159,10 @@ object GroupsParser {
|
|||||||
return if (sh != null) "$sh ($stripped)" else stripped
|
return if (sh != null) "$sh ($stripped)" else stripped
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeParenthesized(name: String): String = name.replace(Regex("""\(.*?\)\s?"""), "").trim()
|
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ParsedGroups(
|
||||||
|
val groups: List<Group>,
|
||||||
|
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import java.io.InputStream
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,4 +36,15 @@ object VyatsuHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает потоковое тело ответа. Вызывающий обязан закрыть
|
||||||
|
* возвращённый [InputStream], чтобы вернуть соединение в пул.
|
||||||
|
*/
|
||||||
|
suspend fun openStream(url: String): InputStream = withContext(Dispatchers.IO) {
|
||||||
|
val resp = client.newCall(Request.Builder().url(url).build()).execute()
|
||||||
|
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||||
|
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||||
|
resp.body!!.byteStream()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import agzam.vyatsu.domain.ScheduleDates
|
|||||||
import agzam.vyatsu.domain.model.Group
|
import agzam.vyatsu.domain.model.Group
|
||||||
import agzam.vyatsu.domain.model.Season
|
import agzam.vyatsu.domain.model.Season
|
||||||
import agzam.vyatsu.domain.repository.GroupRepository
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class GroupRepositoryImpl(
|
class GroupRepositoryImpl(
|
||||||
private val groupDao: GroupDao,
|
private val groupDao: GroupDao,
|
||||||
@@ -40,11 +42,11 @@ class GroupRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refresh() {
|
private suspend fun refresh() {
|
||||||
val html = VyatsuHttpClient.getText(VyatsuUrls.GROUPS_PAGE)
|
val parsed = withContext(Dispatchers.IO) {
|
||||||
val groups = GroupsParser.parseGroups(html)
|
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
|
||||||
val ranges = GroupsParser.parseDateRanges(html)
|
}
|
||||||
val entities = groups.map { group ->
|
val entities = parsed.groups.map { group ->
|
||||||
val range = ranges[group.id]
|
val range = parsed.dateRanges[group.id]
|
||||||
val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
|
val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
|
||||||
val spring = range?.get(Season.SPRING)?.lastOrNull()
|
val spring = range?.get(Season.SPRING)?.lastOrNull()
|
||||||
GroupEntity(
|
GroupEntity(
|
||||||
|
|||||||
@@ -4,23 +4,32 @@ import agzam.vyatsu.domain.model.Day
|
|||||||
import agzam.vyatsu.domain.model.LessonType
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
@@ -33,6 +42,7 @@ import androidx.compose.material3.FilterChipDefaults
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -46,20 +56,32 @@ import androidx.compose.runtime.setValue
|
|||||||
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
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
|
||||||
|
val hPadding = 16.dp
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||||
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
||||||
actions = {
|
actions = {
|
||||||
|
if (state.isLoading && state.schedule != null) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
TextButton(onClick = { onSwitchGroup() }) {
|
TextButton(onClick = { onSwitchGroup() }) {
|
||||||
Text("Сменить группу")
|
Text("Сменить группу")
|
||||||
}
|
}
|
||||||
@@ -69,18 +91,9 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
},
|
},
|
||||||
windowInsets = WindowInsets(0.dp)
|
windowInsets = WindowInsets(0.dp)
|
||||||
)
|
)
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
|
||||||
when {
|
when {
|
||||||
state.isSyncing && state.schedule == null -> {
|
state.schedule == null && state.loadError -> {
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.schedule == null -> {
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
@@ -95,6 +108,15 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.schedule == null -> {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val schedule = state.schedule!!
|
val schedule = state.schedule!!
|
||||||
val weekDays = state.weekStart
|
val weekDays = state.weekStart
|
||||||
@@ -110,7 +132,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth().padding(horizontal = hPadding),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -125,7 +147,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
onSelect = viewModel::selectWeek
|
onSelect = viewModel::selectWeek
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp))
|
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
|
||||||
|
|
||||||
DayChips(
|
DayChips(
|
||||||
days = weekDays,
|
days = weekDays,
|
||||||
@@ -134,14 +156,14 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
onSelect = viewModel::selectDay
|
onSelect = viewModel::selectDay
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(top=0.dp, bottom = 8.dp))
|
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
|
||||||
|
|
||||||
if (day.intervals.all { it.lessons.isEmpty() }) {
|
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(day.intervals) { slotIndex, slot ->
|
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||||
LessonSlot(slotIndex = slotIndex, slot = slot)
|
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||||
@@ -227,7 +249,7 @@ private fun DayChips(
|
|||||||
todayDate: LocalDate?,
|
todayDate: LocalDate?,
|
||||||
onSelect: (LocalDate) -> Unit
|
onSelect: (LocalDate) -> Unit
|
||||||
) {
|
) {
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
|
||||||
items(days, key = { it.date.toEpochDay() }) { day ->
|
items(days, key = { it.date.toEpochDay() }) { day ->
|
||||||
val selected = selectedDate == day.date
|
val selected = selectedDate == day.date
|
||||||
val isTodayChip = todayDate == day.date
|
val isTodayChip = todayDate == day.date
|
||||||
@@ -275,59 +297,97 @@ private fun weekLabel(period: SchedulePeriod): String {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var indexColor = 0
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
HorizontalDivider(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
modifier = Modifier.width(10.dp + hPadding).padding(start = hPadding),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f).padding(end = hPadding))
|
||||||
|
}
|
||||||
if (slot.lessons.isEmpty()) {
|
if (slot.lessons.isEmpty()) {
|
||||||
Text("—", style = MaterialTheme.typography.bodyMedium)
|
Text("—", style = MaterialTheme.typography.bodyMedium)
|
||||||
} else {
|
} else {
|
||||||
slot.lessons.forEach { lesson ->
|
slot.lessons.forEachIndexed { index, lesson ->
|
||||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
var baseSurface = Modifier.fillMaxWidth().padding(vertical = 2.dp)
|
||||||
var any = false
|
if ((indexColor) % 2 != 0) {
|
||||||
if(lesson.type != LessonType.OTHER) {
|
baseSurface = baseSurface.background(
|
||||||
Text(
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
lesson.type.label,
|
);
|
||||||
style = MaterialTheme.typography.bodySmall,
|
}
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
indexColor++
|
||||||
)
|
// XXX:
|
||||||
any = true
|
// Убрать SelectionContainer?
|
||||||
|
// Режим редактирования расписания?
|
||||||
|
// Меню на onClick?
|
||||||
|
// Меню на onLongClick?
|
||||||
|
SelectionContainer() {
|
||||||
|
Column(
|
||||||
|
baseSurface
|
||||||
|
// XXX: combinedClickable?
|
||||||
|
//.combinedClickable(
|
||||||
|
// onClick = {
|
||||||
|
// Toast.makeText(
|
||||||
|
// context,
|
||||||
|
// lesson.raw,
|
||||||
|
// Toast.LENGTH_SHORT
|
||||||
|
// ).show()
|
||||||
|
// },
|
||||||
|
// onLongClick = {},
|
||||||
|
// // TODO: onLongClickLabel = "(Accessibility)"
|
||||||
|
//),
|
||||||
|
) {
|
||||||
|
var any = false
|
||||||
|
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
if (lesson.type == LessonType.OTHER) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
lesson.type.label
|
||||||
|
},
|
||||||
|
lesson.subgroup,
|
||||||
|
lesson.teacher,
|
||||||
|
lesson.room
|
||||||
|
).filter { it.isNotBlank() }.joinToString(" · ")
|
||||||
|
|
||||||
|
if (meta.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
meta,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = hPadding)
|
||||||
|
)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
||||||
|
if (subject.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
subject,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(horizontal = hPadding)
|
||||||
|
)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!any) {
|
||||||
|
Text(
|
||||||
|
"пусто",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = hPadding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
|
||||||
if(subject.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
subject,
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
any = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val meta = listOfNotNull(
|
|
||||||
lesson.subgroup,
|
|
||||||
lesson.teacher,
|
|
||||||
lesson.room
|
|
||||||
).filter { it.isNotBlank() }.joinToString(" · ")
|
|
||||||
|
|
||||||
if(meta.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
meta,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
any = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!any) {
|
|
||||||
Text(
|
|
||||||
"-",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ScheduleViewModel(
|
|||||||
|
|
||||||
fun selectGroup(groupId: String) {
|
fun selectGroup(groupId: String) {
|
||||||
currentGroupId = groupId
|
currentGroupId = groupId
|
||||||
|
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.setSelectedGroup(groupId)
|
preferencesRepository.setSelectedGroup(groupId)
|
||||||
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||||
@@ -61,7 +62,7 @@ class ScheduleViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
syncIfMissingToday(groupId)
|
syncIfMissing(groupId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,9 @@ class ScheduleViewModel(
|
|||||||
schedule = schedule,
|
schedule = schedule,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
dayDate = dayDate,
|
dayDate = dayDate,
|
||||||
todayDate = todayDate
|
todayDate = todayDate,
|
||||||
|
isLoading = false,
|
||||||
|
loadError = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,30 +131,36 @@ class ScheduleViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncIfMissingToday(groupId: String) {
|
private suspend fun syncIfMissing(groupId: String) {
|
||||||
val schedule = scheduleRepository.getSchedule(groupId)
|
if (scheduleRepository.getSchedule(groupId) != null) {
|
||||||
val today = ScheduleDates.moscowToday()
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
val hasTodayPeriod = schedule?.periodOf(today) != null
|
return
|
||||||
if (hasTodayPeriod) return
|
}
|
||||||
_uiState.value = _uiState.value.copy(isSyncing = true)
|
loadSchedule(groupId)
|
||||||
scheduleRepository.sync(groupId)
|
}
|
||||||
_uiState.value = _uiState.value.copy(isSyncing = false)
|
|
||||||
|
private suspend fun loadSchedule(groupId: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||||
|
val schedule = scheduleRepository.sync(groupId)
|
||||||
|
_uiState.value = if (schedule != null) {
|
||||||
|
_uiState.value.copy(isLoading = false, loadError = false)
|
||||||
|
} else {
|
||||||
|
_uiState.value.copy(isLoading = false, loadError = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun switchGroup() {
|
fun switchGroup() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
currentGroupId = null
|
currentGroupId = null
|
||||||
preferencesRepository.clearSelectedGroup()
|
preferencesRepository.clearSelectedGroup()
|
||||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
_uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
val groupId = currentGroupId ?: return
|
val groupId = currentGroupId ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
loadSchedule(groupId)
|
||||||
scheduleRepository.sync(groupId)
|
|
||||||
_uiState.value = _uiState.value.copy(isRefreshing = false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +184,8 @@ class ScheduleViewModel(
|
|||||||
|
|
||||||
data class ScheduleUiState(
|
data class ScheduleUiState(
|
||||||
val noGroup: Boolean = false,
|
val noGroup: Boolean = false,
|
||||||
val isRefreshing: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSyncing: Boolean = false,
|
val loadError: Boolean = false,
|
||||||
val schedule: Schedule? = null,
|
val schedule: Schedule? = null,
|
||||||
val groupName: String? = null,
|
val groupName: String? = null,
|
||||||
val weekStart: LocalDate? = null,
|
val weekStart: LocalDate? = null,
|
||||||
|
|||||||
Reference in New Issue
Block a user