Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014896aaea | ||
|
|
9f6cf3ef30 | ||
|
|
d1923d3296 | ||
|
|
686976347e | ||
|
|
699bcd81f5 | ||
|
|
050f835a33 | ||
|
|
5fee57d93a | ||
|
|
36711d1bf3 | ||
|
|
c2a9cfb7e7 | ||
|
|
467c1a735f | ||
|
|
28965e6aed | ||
|
|
181f8b2f2a | ||
|
|
84ae97b906 | ||
|
|
2069ec811a |
@@ -85,6 +85,9 @@ jobs:
|
|||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
env:
|
env:
|
||||||
|
# Версия приложения — дата, сгенерированная на предыдущем шаге.
|
||||||
|
# Читается в app/build.gradle.kts как versionName (для BuildConfig).
|
||||||
|
APP_VERSION: ${{ steps.version.outputs.version_name }}
|
||||||
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
|
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
|
||||||
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
|
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
@@ -112,9 +115,3 @@ jobs:
|
|||||||
files: ${{ steps.apk.outputs.apk_path }}
|
files: ${{ steps.apk.outputs.apk_path }}
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
server_url: ${{ gitea.server_url }}
|
server_url: ${{ gitea.server_url }}
|
||||||
|
|
||||||
- name: Upload APK as artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: vyatsu-schedule-${{ steps.version.outputs.version_name }}
|
|
||||||
path: ${{ steps.apk.outputs.apk_path }}
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ android {
|
|||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
|
||||||
|
// Локально и при отсутствии переменной — fallback "1.0".
|
||||||
|
versionName = System.getenv("APP_VERSION") ?: "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,5 +41,5 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
|
|||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository) }
|
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
|
||||||
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
|||||||
|
|
||||||
private val svgBuilder = StringBuilder()
|
private val svgBuilder = StringBuilder()
|
||||||
private val currentPath = StringBuilder()
|
private val currentPath = StringBuilder()
|
||||||
private val tableSvg = StringBuilder()
|
val tableSvg = StringBuilder()
|
||||||
|
|
||||||
|
|
||||||
private val currentSubPathLines = mutableListOf<PdfLine>()
|
private val currentSubPathLines = mutableListOf<PdfLine>()
|
||||||
@@ -52,7 +52,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
|||||||
val width = mediaBox.width
|
val width = mediaBox.width
|
||||||
val height = mediaBox.height
|
val height = mediaBox.height
|
||||||
return """
|
return """
|
||||||
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style='font-size: 50%;'>
|
||||||
<g>${svgBuilder}</g>
|
<g>${svgBuilder}</g>
|
||||||
<g>${tableSvg}</g>
|
<g>${tableSvg}</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -211,7 +211,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
|||||||
val bottom = ys[ii+1]
|
val bottom = ys[ii+1]
|
||||||
cells.add(TableCell(left, top, right-left, bottom-top))
|
cells.add(TableCell(left, top, right-left, bottom-top))
|
||||||
|
|
||||||
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f" else "#0"}${if (ii % 2 == 0) "f0" else "00"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
||||||
}
|
}
|
||||||
columns.add(TableColumn(left, right-left, cells))
|
columns.add(TableColumn(left, right-left, cells))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agzam.vyatsu.data.remote.pdf
|
|||||||
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
import agzam.vyatsu.domain.model.Day
|
||||||
import agzam.vyatsu.domain.model.Lesson
|
import agzam.vyatsu.domain.model.Lesson
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||||
@@ -25,6 +26,10 @@ object PdfScheduleParser {
|
|||||||
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
||||||
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
||||||
|
|
||||||
|
private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
|
||||||
|
private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } )
|
||||||
|
private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
fun parse(pdfBytes: ByteArray): List<Day> {
|
fun parse(pdfBytes: ByteArray): List<Day> {
|
||||||
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
||||||
val doc = try {
|
val doc = try {
|
||||||
@@ -35,6 +40,8 @@ object PdfScheduleParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val days = mutableListOf<Day>()
|
val days = mutableListOf<Day>()
|
||||||
|
var group: String? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (pageIndex in 0 until doc.numberOfPages) {
|
for (pageIndex in 0 until doc.numberOfPages) {
|
||||||
Log.v(TAG, "============ Page $pageIndex ============")
|
Log.v(TAG, "============ Page $pageIndex ============")
|
||||||
@@ -45,12 +52,6 @@ object PdfScheduleParser {
|
|||||||
svg.processPage(page)
|
svg.processPage(page)
|
||||||
val cols = svg.asTable()
|
val cols = svg.asTable()
|
||||||
|
|
||||||
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
|
||||||
Log.v(SVG_TAG, "====")
|
|
||||||
for(line in svg.asSvg().split("\n")) {
|
|
||||||
Log.v(SVG_TAG, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
|
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
|
||||||
|
|
||||||
// Парсинг текста
|
// Парсинг текста
|
||||||
@@ -60,6 +61,11 @@ object PdfScheduleParser {
|
|||||||
textStripper.getText(doc)
|
textStripper.getText(doc)
|
||||||
val texts = textStripper.blocks
|
val texts = textStripper.blocks
|
||||||
|
|
||||||
|
for(text in texts) {
|
||||||
|
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
|
||||||
|
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
|
||||||
|
}
|
||||||
|
|
||||||
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
|
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
|
||||||
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
|
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
|
||||||
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
|
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
|
||||||
@@ -100,10 +106,17 @@ object PdfScheduleParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
for (dayCell in daysColumn.cells) {
|
for (dayCell in daysColumn.cells) {
|
||||||
val day = content(dayCell).split(' ')
|
val day = content(dayCell).split(' ')
|
||||||
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
if(day.size == 1 && day.first() == "День") {
|
||||||
|
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
|
||||||
|
group = content(groupCell)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if(day.size != 2) {
|
||||||
|
Log.w(TAG, "Day data is not in format <day> <date>: $day")
|
||||||
|
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
||||||
|
}
|
||||||
|
|
||||||
val date = dateFrom(day[1]) ?: continue
|
val date = dateFrom(day[1]) ?: continue
|
||||||
val intervals = mutableListOf<TimeSlot>()
|
val intervals = mutableListOf<TimeSlot>()
|
||||||
@@ -124,7 +137,55 @@ object PdfScheduleParser {
|
|||||||
val content = content(lessonCell)
|
val content = content(lessonCell)
|
||||||
Log.v(TAG, "- $content")
|
Log.v(TAG, "- $content")
|
||||||
// TODO: парсинг контента лекции
|
// TODO: парсинг контента лекции
|
||||||
lessons.add(Lesson(raw=content))
|
|
||||||
|
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
|
||||||
|
var found = 0
|
||||||
|
var currentStart = 0
|
||||||
|
for (roomMatch in ROOM_REGEX.findAll(content)) {
|
||||||
|
found++
|
||||||
|
|
||||||
|
val raw = content.substring(currentStart, roomMatch.range.last + 1)
|
||||||
|
currentStart = roomMatch.range.last + 1
|
||||||
|
|
||||||
|
// Предполагаемый формат: [группа], [XX подгруппа] <предмет> <тип> [id + тип] <ФИО> <кабинет>
|
||||||
|
|
||||||
|
val room = roomMatch.value
|
||||||
|
|
||||||
|
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList()
|
||||||
|
if(typeMatch.isEmpty()) {
|
||||||
|
lessons.add(Lesson(
|
||||||
|
raw=raw,
|
||||||
|
room=room,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var subject = raw.substring(0, typeMatch.first().range.first).trim() // TODO: сейчас [группа], [XX подгруппа] <предмет> - можно достать подгруппу
|
||||||
|
val type = LessonType.fromRaw(typeMatch.first().value.trim())
|
||||||
|
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
|
||||||
|
var subgroup: String? = null
|
||||||
|
Log.d(TAG, "$subject $group")
|
||||||
|
if(group != null && subject.startsWith("$group,")) {
|
||||||
|
subject = subject.substring(group.length + 1).trim()
|
||||||
|
val subgroupMatch = SUBGROUP_REGEX.find(subject)
|
||||||
|
if(subgroupMatch != null) {
|
||||||
|
subgroup = subgroupMatch.value.trim()
|
||||||
|
subject = subject.removeRange(subgroupMatch.range).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
lessons.add(Lesson(
|
||||||
|
raw=raw,
|
||||||
|
subject=subject,
|
||||||
|
room=room,
|
||||||
|
type=type,
|
||||||
|
teacher=teacher,
|
||||||
|
subgroup=subgroup
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if(found == 0) lessons.add(Lesson(raw=content))
|
||||||
|
|
||||||
}
|
}
|
||||||
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
|
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
|
||||||
}
|
}
|
||||||
@@ -133,6 +194,18 @@ object PdfScheduleParser {
|
|||||||
for (text in texts.filter { it.rotation != 0f }) {
|
for (text in texts.filter { it.rotation != 0f }) {
|
||||||
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
|
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
||||||
|
Log.v(SVG_TAG, "====")
|
||||||
|
for(line in svg.asSvg().split("\n")) {
|
||||||
|
if(line.length > 250) {
|
||||||
|
Log.v(SVG_TAG, "length: " + line.length)
|
||||||
|
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Log.v(SVG_TAG, line)
|
||||||
|
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Parsing error", e)
|
Log.e(TAG, "Parsing error", e)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.tom_roush.pdfbox.text.TextPosition
|
|||||||
* Парсер блоков текста из PDF
|
* Парсер блоков текста из PDF
|
||||||
* @author Agzam
|
* @author Agzam
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class PdfTextParser : PDFTextStripper() {
|
class PdfTextParser : PDFTextStripper() {
|
||||||
|
|
||||||
data class TextBlock(
|
data class TextBlock(
|
||||||
@@ -25,9 +26,32 @@ class PdfTextParser : PDFTextStripper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
|
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
|
||||||
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
|
if (textPositions.isNullOrEmpty()) return
|
||||||
val first = textPositions.first()
|
|
||||||
val last = textPositions.last()
|
val currentWordChars = mutableListOf<TextPosition>()
|
||||||
|
|
||||||
|
for (tp in textPositions) {
|
||||||
|
val unicode = tp.unicode
|
||||||
|
|
||||||
|
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
|
||||||
|
addWordBlock(currentWordChars)
|
||||||
|
currentWordChars.clear()
|
||||||
|
} else {
|
||||||
|
currentWordChars.add(tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addWordBlock(currentWordChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addWordBlock(wordChars: List<TextPosition>) {
|
||||||
|
if (wordChars.isEmpty()) return
|
||||||
|
|
||||||
|
val first = wordChars.first()
|
||||||
|
val last = wordChars.last()
|
||||||
|
|
||||||
|
val wordText = wordChars.joinToString("") { it.unicode }.trim()
|
||||||
|
if (wordText.isEmpty()) return
|
||||||
|
|
||||||
val matrix = first.textMatrix
|
val matrix = first.textMatrix
|
||||||
val rotation = first.dir
|
val rotation = first.dir
|
||||||
@@ -36,10 +60,17 @@ class PdfTextParser : PDFTextStripper() {
|
|||||||
|
|
||||||
val x = matrix.translateX
|
val x = matrix.translateX
|
||||||
val y = matrix.translateY - first.heightDir
|
val y = matrix.translateY - first.heightDir
|
||||||
|
|
||||||
val height = first.heightDir
|
val height = first.heightDir
|
||||||
|
|
||||||
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
|
blocks.add(
|
||||||
}
|
TextBlock(
|
||||||
|
text = wordText,
|
||||||
|
x = x,
|
||||||
|
y = currentPage.mediaBox.height - y - height,
|
||||||
|
width = totalWidth,
|
||||||
|
height = height,
|
||||||
|
rotation = rotation
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +157,7 @@ class ScheduleRepositoryImpl(
|
|||||||
val lessonsByDay = lessonEntities.groupBy { it.dayId }
|
val lessonsByDay = lessonEntities.groupBy { it.dayId }
|
||||||
val days = dayEntities
|
val days = dayEntities
|
||||||
.sortedBy { it.date }
|
.sortedBy { it.date }
|
||||||
|
.distinctBy { it.date }
|
||||||
.map { day ->
|
.map { day ->
|
||||||
val lessons = lessonsByDay[day.id].orEmpty()
|
val lessons = lessonsByDay[day.id].orEmpty()
|
||||||
.groupBy { it.slotIndex }
|
.groupBy { it.slotIndex }
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package agzam.vyatsu.ui.schedule
|
package agzam.vyatsu.ui.schedule
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
import agzam.vyatsu.domain.model.Day
|
||||||
|
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 androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
@@ -12,14 +15,18 @@ 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.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
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.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
@@ -33,8 +40,12 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -47,7 +58,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Расписание") },
|
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(onClick = { onSwitchGroup() }) {
|
TextButton(onClick = { onSwitchGroup() }) {
|
||||||
Text("Сменить группу")
|
Text("Сменить группу")
|
||||||
@@ -56,7 +67,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
|
windowInsets = WindowInsets(0.dp)
|
||||||
)
|
)
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
||||||
when {
|
when {
|
||||||
@@ -98,31 +109,23 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
val isTodayDay = day.date == state.todayDate
|
val isTodayDay = day.date == state.todayDate
|
||||||
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||||
|
|
||||||
Text(
|
|
||||||
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = { viewModel.clearSelectedPeriod() }) {
|
Text(
|
||||||
Text("Очистить эту неделю")
|
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||||
}
|
style = MaterialTheme.typography.titleMedium,
|
||||||
TextButton(onClick = { viewModel.clearAll() }) {
|
modifier = Modifier.weight(1f)
|
||||||
Text("Очистить всё")
|
)
|
||||||
}
|
WeekSelector(
|
||||||
}
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
|
||||||
|
|
||||||
WeekChips(
|
|
||||||
periods = schedule.periods,
|
periods = schedule.periods,
|
||||||
selectedWeekStart = state.weekStart,
|
selectedWeekStart = state.weekStart,
|
||||||
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
|
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
|
||||||
onSelect = viewModel::selectWeek
|
onSelect = viewModel::selectWeek
|
||||||
)
|
)
|
||||||
|
}
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp))
|
||||||
|
|
||||||
DayChips(
|
DayChips(
|
||||||
days = weekDays,
|
days = weekDays,
|
||||||
@@ -131,7 +134,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
onSelect = viewModel::selectDay
|
onSelect = viewModel::selectDay
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(Modifier.padding(top=0.dp, bottom = 8.dp))
|
||||||
|
|
||||||
if (day.intervals.all { it.lessons.isEmpty() }) {
|
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||||
@@ -152,41 +155,69 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekChips(
|
private fun WeekSelector(
|
||||||
periods: List<SchedulePeriod>,
|
periods: List<SchedulePeriod>,
|
||||||
selectedWeekStart: LocalDate?,
|
selectedWeekStart: LocalDate?,
|
||||||
todayWeekStart: LocalDate?,
|
todayWeekStart: LocalDate?,
|
||||||
onSelect: (LocalDate) -> Unit
|
onSelect: (LocalDate) -> Unit
|
||||||
) {
|
) {
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
|
||||||
items(periods, key = { it.weekStart.toEpochDay() }) { period ->
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val selected = selectedWeekStart == period.weekStart
|
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.clickable(enabled = true, onClick = { expanded = true })
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
selected?.let(::weekLabel) ?: "Период",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
modifier = Modifier.widthIn(min = 220.dp)
|
||||||
|
) {
|
||||||
|
periods.forEach { period ->
|
||||||
val isTodayWeek = todayWeekStart == period.weekStart
|
val isTodayWeek = todayWeekStart == period.weekStart
|
||||||
FilterChip(
|
DropdownMenuItem(
|
||||||
selected = selected,
|
text = {
|
||||||
onClick = { onSelect(period.weekStart) },
|
|
||||||
label = {
|
|
||||||
Text(
|
Text(
|
||||||
weekLabel(period),
|
weekLabel(period),
|
||||||
style = MaterialTheme.typography.labelLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(vertical = 2.dp),
|
trailingIcon = if (isTodayWeek) {
|
||||||
border = if (isTodayWeek && !selected) {
|
{
|
||||||
FilterChipDefaults.filterChipBorder(
|
Text(
|
||||||
borderColor = MaterialTheme.colorScheme.primary,
|
"сегодня",
|
||||||
borderWidth = 1.dp,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
enabled = true,
|
color = MaterialTheme.colorScheme.primary
|
||||||
selected = false
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
|
null
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(8.dp)
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelect(period.weekStart)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -255,22 +286,48 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
|||||||
} else {
|
} else {
|
||||||
slot.lessons.forEach { lesson ->
|
slot.lessons.forEach { lesson ->
|
||||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
|
var any = false
|
||||||
|
if(lesson.type != LessonType.OTHER) {
|
||||||
Text(
|
Text(
|
||||||
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
listOfNotNull(
|
|
||||||
lesson.type.label,
|
lesson.type.label,
|
||||||
lesson.subgroup,
|
|
||||||
lesson.teacher,
|
|
||||||
lesson.room
|
|
||||||
)
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.joinToString(" · "),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agzam.vyatsu.ui.schedule
|
|||||||
|
|
||||||
import agzam.vyatsu.domain.ScheduleDates
|
import agzam.vyatsu.domain.ScheduleDates
|
||||||
import agzam.vyatsu.domain.model.Schedule
|
import agzam.vyatsu.domain.model.Schedule
|
||||||
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
@@ -14,7 +15,8 @@ import java.time.LocalDate
|
|||||||
|
|
||||||
class ScheduleViewModel(
|
class ScheduleViewModel(
|
||||||
private val scheduleRepository: ScheduleRepository,
|
private val scheduleRepository: ScheduleRepository,
|
||||||
private val preferencesRepository: PreferencesRepository
|
private val preferencesRepository: PreferencesRepository,
|
||||||
|
private val groupRepository: GroupRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||||
@@ -28,7 +30,7 @@ class ScheduleViewModel(
|
|||||||
if (groupId != null) {
|
if (groupId != null) {
|
||||||
selectGroup(groupId)
|
selectGroup(groupId)
|
||||||
} else {
|
} else {
|
||||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
_uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,7 @@ class ScheduleViewModel(
|
|||||||
currentGroupId = groupId
|
currentGroupId = groupId
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.setSelectedGroup(groupId)
|
preferencesRepository.setSelectedGroup(groupId)
|
||||||
|
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||||
|
|
||||||
val savedWeekStart = runCatching {
|
val savedWeekStart = runCatching {
|
||||||
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
|
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
|
||||||
@@ -46,6 +49,7 @@ class ScheduleViewModel(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
noGroup = false,
|
noGroup = false,
|
||||||
|
groupName = groupName,
|
||||||
weekStart = savedWeekStart,
|
weekStart = savedWeekStart,
|
||||||
dayDate = savedDayDate
|
dayDate = savedDayDate
|
||||||
)
|
)
|
||||||
@@ -174,6 +178,7 @@ data class ScheduleUiState(
|
|||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val isSyncing: Boolean = false,
|
val isSyncing: Boolean = false,
|
||||||
val schedule: Schedule? = null,
|
val schedule: Schedule? = null,
|
||||||
|
val groupName: String? = null,
|
||||||
val weekStart: LocalDate? = null,
|
val weekStart: LocalDate? = null,
|
||||||
val dayDate: LocalDate? = null,
|
val dayDate: LocalDate? = null,
|
||||||
val todayDate: LocalDate? = null
|
val todayDate: LocalDate? = null
|
||||||
|
|||||||
Reference in New Issue
Block a user