forked from agzam/vyatsu-schedule
init
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
GroupEntity::class,
|
||||
ScheduleEntity::class,
|
||||
PreferenceEntity::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun groupDao(): GroupDao
|
||||
abstract fun scheduleDao(): ScheduleDao
|
||||
abstract fun preferencesDao(): PreferencesDao
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.time.LocalDate
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DayDto(
|
||||
val date: String,
|
||||
val slots: List<SlotDto> = emptyList()
|
||||
) {
|
||||
fun toDomain(): Day = Day(
|
||||
date = LocalDate.parse(date),
|
||||
intervals = slots.map { it.toDomain() }
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromDomain(d: Day): DayDto = DayDto(
|
||||
date = d.date.toString(),
|
||||
slots = d.intervals.map { SlotDto.fromDomain(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface GroupDao {
|
||||
@Query("SELECT * FROM groups ORDER BY name")
|
||||
fun observeAll(): Flow<List<GroupEntity>>
|
||||
|
||||
@Query("SELECT * FROM groups WHERE id = :id")
|
||||
suspend fun getById(id: String): GroupEntity?
|
||||
|
||||
@Query("SELECT * FROM groups WHERE faculty = :faculty ORDER BY name")
|
||||
suspend fun getByFaculty(faculty: String): List<GroupEntity>
|
||||
|
||||
@Query("SELECT DISTINCT faculty FROM groups ORDER BY faculty")
|
||||
suspend fun getFaculties(): List<String>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(groups: List<GroupEntity>)
|
||||
|
||||
@Query("SELECT MAX(lastSyncedAt) FROM groups")
|
||||
suspend fun maxSyncedAt(): Long?
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "groups")
|
||||
data class GroupEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val name: String,
|
||||
val faculty: String,
|
||||
val autumnFirst: String,
|
||||
val autumnSecond: String,
|
||||
val springFirst: String,
|
||||
val springSecond: String,
|
||||
val lastSyncedAt: Long
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import agzam.vyatsu.domain.model.Lesson
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LessonDto(
|
||||
val raw: String = "",
|
||||
val room: String? = null,
|
||||
val group: String? = null,
|
||||
val subgroup: String? = null,
|
||||
val subject: String? = null,
|
||||
val type: String = "",
|
||||
val teacher: String? = null
|
||||
) {
|
||||
fun toDomain(): Lesson = Lesson(
|
||||
raw = raw,
|
||||
room = room?.takeIf { it.isNotBlank() },
|
||||
group = group?.takeIf { it.isNotBlank() },
|
||||
subgroup = subgroup?.takeIf { it.isNotBlank() },
|
||||
subject = subject?.takeIf { it.isNotBlank() },
|
||||
type = runCatching { LessonType.valueOf(type) }.getOrDefault(LessonType.OTHER),
|
||||
teacher = teacher?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromDomain(l: Lesson): LessonDto = LessonDto(
|
||||
raw = l.raw,
|
||||
room = l.room,
|
||||
group = l.group,
|
||||
subgroup = l.subgroup,
|
||||
subject = l.subject,
|
||||
type = l.type.name,
|
||||
teacher = l.teacher
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "preferences")
|
||||
data class PreferenceEntity(
|
||||
@PrimaryKey val key: String,
|
||||
val value: String
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface PreferencesDao {
|
||||
@Query("SELECT value FROM preferences WHERE key = 'selectedGroupId'")
|
||||
suspend fun selectedGroupId(): String?
|
||||
|
||||
@Query(
|
||||
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedGroupId', :groupId)"
|
||||
)
|
||||
suspend fun setSelectedGroup(groupId: String)
|
||||
|
||||
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
|
||||
suspend fun clearSelectedGroup()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
||||
class ScheduleCodec(moshi: Moshi) {
|
||||
private val type: java.lang.reflect.Type =
|
||||
Types.newParameterizedType(List::class.java, DayDto::class.java)
|
||||
private val adapter = moshi.adapter<List<DayDto>>(type)
|
||||
|
||||
fun toJson(days: List<Day>): String {
|
||||
val dto = days.map { DayDto.fromDomain(it) }
|
||||
return adapter.toJson(dto)
|
||||
}
|
||||
|
||||
fun fromJson(json: String): List<Day> {
|
||||
return try {
|
||||
val dto = adapter.fromJson(json) ?: return emptyList()
|
||||
dto.map { it.toDomain() }
|
||||
} catch (t: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ScheduleDao {
|
||||
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
|
||||
fun observe(groupId: String): Flow<ScheduleEntity?>
|
||||
|
||||
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
|
||||
suspend fun get(groupId: String): ScheduleEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: ScheduleEntity)
|
||||
|
||||
@Query("DELETE FROM schedules WHERE groupId = :groupId")
|
||||
suspend fun delete(groupId: String)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "schedules")
|
||||
data class ScheduleEntity(
|
||||
@PrimaryKey val groupId: String,
|
||||
val season: String,
|
||||
val dateRangeFirst: String,
|
||||
val dateRangeSecond: String,
|
||||
val weeksJson: String,
|
||||
val lastSyncedAt: Long
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.time.LocalTime
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SlotDto(
|
||||
val start: String,
|
||||
val end: String,
|
||||
val lessons: List<LessonDto> = emptyList()
|
||||
) {
|
||||
fun toDomain(): TimeSlot = TimeSlot(
|
||||
start = LocalTime.parse(start),
|
||||
end = LocalTime.parse(end),
|
||||
lessons = lessons.map { it.toDomain() }
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromDomain(s: TimeSlot): SlotDto = SlotDto(
|
||||
start = s.start.toString(),
|
||||
end = s.end.toString(),
|
||||
lessons = s.lessons.map { LessonDto.fromDomain(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package agzam.vyatsu.data.remote
|
||||
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import android.util.Log
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
/**
|
||||
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
||||
* @author Agzam
|
||||
*/
|
||||
object GroupsParser {
|
||||
|
||||
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
||||
|
||||
private val shorthands = mapOf(
|
||||
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
|
||||
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
|
||||
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
|
||||
"Факультет лингвистики (ОРУ)" to "ФЛ",
|
||||
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
|
||||
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
|
||||
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
|
||||
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
|
||||
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
|
||||
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
|
||||
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
|
||||
"Электротехнический факультет (ОРУ)" to "ЭТФ",
|
||||
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
|
||||
)
|
||||
|
||||
/**
|
||||
* Извлекает список групп с HTML страницы
|
||||
*/
|
||||
fun parseGroups(html: String): List<Group> {
|
||||
val doc = Jsoup.parse(html)
|
||||
val container = doc.selectFirst("div.column-center_rasp") ?: return emptyList()
|
||||
val tables = container.select("table[style~=border: none !important]")
|
||||
|
||||
return tables.flatMap { table ->
|
||||
table.select("> tbody > tr, > tr").flatMap { row ->
|
||||
Log.v("ROW", row.html());
|
||||
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) //
|
||||
|
||||
row.select("div.grpPeriod").map { grp ->
|
||||
Group(
|
||||
id = grp.attr("data-grp_period_id").dropLast(1),
|
||||
name = removeParenthesized(grp.text()),
|
||||
faculty = faculty
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseDateRanges(html: String): Map<String, Map<Season, List<DateRange>>> {
|
||||
val result = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||
groupPattern.findAll(html).forEach { m ->
|
||||
val groupId = m.groupValues[1]
|
||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||
result
|
||||
.getOrPut(groupId) { hashMapOf(Season.AUTUMN to mutableListOf(), Season.SPRING to mutableListOf()) }
|
||||
.getOrPut(season) { mutableListOf() }
|
||||
.add(range)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun shortname(name: String): String {
|
||||
val stripped = removeParenthesized(name)
|
||||
val sh = shorthands[name]
|
||||
return if (sh != null) "$sh ($stripped)" else stripped
|
||||
}
|
||||
|
||||
private fun removeParenthesized(name: String): String = name.replace(Regex("""\(.*?\)\s?"""), "").trim()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agzam.vyatsu.data.remote
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* @author Agzam
|
||||
*/
|
||||
object VyatsuHttpClient {
|
||||
|
||||
private const val TAG = "HTTP"
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
suspend fun getText(url: String): String = withContext(Dispatchers.IO) {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||
resp.body!!.string()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||
resp.body!!.bytes()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package agzam.vyatsu.data.remote
|
||||
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
|
||||
object VyatsuUrls {
|
||||
|
||||
const val GROUPS_PAGE = "https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/raspisanie-zanyatiy-dlya-studentov.html"
|
||||
const val PDF_BASEURL = "https://www.vyatsu.ru/reports/schedule/Group"
|
||||
|
||||
fun groupPdfUrl(groupId: String, season: Season, range: DateRange): String = "$PDF_BASEURL/${groupId}_${season.apiKey}_${range.first}_${range.second}.pdf"
|
||||
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package agzam.vyatsu.data.remote.pdf
|
||||
|
||||
import android.graphics.Path
|
||||
import android.graphics.PointF
|
||||
import com.tom_roush.pdfbox.contentstream.PDFGraphicsStreamEngine
|
||||
import com.tom_roush.pdfbox.cos.COSName
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPage
|
||||
import com.tom_roush.pdfbox.pdmodel.graphics.color.PDColor
|
||||
import com.tom_roush.pdfbox.pdmodel.graphics.image.PDImage
|
||||
import java.util.Locale
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* - Парсит страницу PDF в набор линий
|
||||
* - Позволяет получить таблицу
|
||||
* - Позволяет конвертировать в SVG (для отладки)
|
||||
*
|
||||
* Требуется вызов [processPage] для парсинга
|
||||
* @author Agzam
|
||||
*/
|
||||
class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
||||
|
||||
data class PdfLine(val x1: Float, val y1: Float, val x2: Float, val y2: Float) {
|
||||
val left: Float get() = min(x1, x2)
|
||||
val right: Float get() = max(x1, x2)
|
||||
val top: Float get() = min(y1, y2)
|
||||
val bottom: Float get() = max(y1, y2)
|
||||
}
|
||||
data class TableColumn(val x: Float, val width: Float, val cells: List<TableCell>) {
|
||||
val right: Float get() = x + width
|
||||
}
|
||||
data class TableCell(val x: Float, val y: Float, val width: Float, val height: Float) {
|
||||
val centerY: Float get() = y + height/2f
|
||||
}
|
||||
|
||||
private val _lines = mutableListOf<PdfLine>()
|
||||
|
||||
private val svgBuilder = StringBuilder()
|
||||
private val currentPath = StringBuilder()
|
||||
private val tableSvg = StringBuilder()
|
||||
|
||||
|
||||
private val currentSubPathLines = mutableListOf<PdfLine>()
|
||||
private var lastPoint = PointF(0f, 0f)
|
||||
|
||||
/**
|
||||
* Представляет страницу в SVG формате
|
||||
*/
|
||||
fun asSvg(): String {
|
||||
val mediaBox = page.mediaBox
|
||||
val width = mediaBox.width
|
||||
val height = mediaBox.height
|
||||
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">
|
||||
<g>${svgBuilder}</g>
|
||||
<g>${tableSvg}</g>
|
||||
</svg>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
override fun appendRectangle(rp0: PointF?, rp1: PointF?, rp2: PointF?, rp3: PointF?) {
|
||||
if (rp0 == null || rp1 == null || rp2 == null || rp3 == null) return
|
||||
val p0 = t(rp0)
|
||||
val p1 = t(rp1)
|
||||
val p2 = t(rp2)
|
||||
val p3 = t(rp3)
|
||||
currentPath.append(String.format(Locale.US, "M %.2f %.2f L %.2f %.2f L %.2f %.2f L %.2f %.2f Z ", p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y))
|
||||
currentSubPathLines.add(PdfLine(p0.x, p0.y, p1.x, p1.y))
|
||||
currentSubPathLines.add(PdfLine(p1.x, p1.y, p2.x, p2.y))
|
||||
currentSubPathLines.add(PdfLine(p2.x, p2.y, p3.x, p3.y))
|
||||
currentSubPathLines.add(PdfLine(p3.x, p3.y, p0.x, p0.y))
|
||||
lastPoint.set(p3.x, p3.y)
|
||||
}
|
||||
|
||||
override fun moveTo(rx: Float, ry: Float) {
|
||||
val x = tx(rx)
|
||||
val y = ty(ry)
|
||||
currentPath.append(String.format(Locale.US, "M %.2f %.2f ", x, y))
|
||||
lastPoint.set(x, y)
|
||||
}
|
||||
|
||||
override fun lineTo(rx: Float, ry: Float) {
|
||||
val x = tx(rx)
|
||||
val y = ty(ry)
|
||||
currentPath.append(String.format(Locale.US, "L %.2f %.2f ", x, y))
|
||||
currentSubPathLines.add(PdfLine(lastPoint.x, lastPoint.y, x, y))
|
||||
lastPoint.set(x, y)
|
||||
}
|
||||
|
||||
override fun curveTo(rcx1: Float, rcy1: Float, rcx2: Float, rcy2: Float, ex: Float, ey: Float) {
|
||||
val cx1 = tx(rcx1)
|
||||
val cy1 = ty(rcy1)
|
||||
val cx2 = tx(rcx2)
|
||||
val cy2 = ty(rcy2)
|
||||
val endX = tx(ex)
|
||||
val endY = ty(ey)
|
||||
currentPath.append(String.format(Locale.US, "C %.2f %.2f, %.2f %.2f, %.2f %.2f ", cx1, cy1, cx2, cy2, endX, endY))
|
||||
lastPoint.set(endX, endY)
|
||||
}
|
||||
|
||||
override fun getCurrentPoint(): PointF {
|
||||
return lastPoint
|
||||
}
|
||||
|
||||
override fun closePath() {
|
||||
currentPath.append("Z ")
|
||||
}
|
||||
|
||||
override fun endPath() {
|
||||
currentPath.clear()
|
||||
currentSubPathLines.clear()
|
||||
}
|
||||
|
||||
override fun strokePath() {
|
||||
val strokeColor = graphicsState.strokingColor.toRGBHex()
|
||||
val lineWidth = graphicsState.lineWidth
|
||||
if (currentPath.isNotEmpty()) {
|
||||
svgBuilder.append("<path d=\"$currentPath\" stroke=\"$strokeColor\" stroke-width=\"$lineWidth\" fill=\"none\" />\n")
|
||||
_lines.addAll(currentSubPathLines)
|
||||
}
|
||||
currentPath.clear()
|
||||
currentSubPathLines.clear()
|
||||
}
|
||||
|
||||
override fun fillPath(p0: Path.FillType?) {
|
||||
val fillColor = graphicsState.nonStrokingColor.toRGBHex()
|
||||
val rule = if (p0 == Path.FillType.EVEN_ODD) "evenodd" else "nonzero"
|
||||
if (currentPath.isNotEmpty()) {
|
||||
svgBuilder.append("<path d=\"$currentPath\" fill=\"$fillColor\" fill-rule=\"$rule\" />\n")
|
||||
_lines.addAll(currentSubPathLines)
|
||||
}
|
||||
currentPath.clear()
|
||||
currentSubPathLines.clear()
|
||||
}
|
||||
|
||||
override fun fillAndStrokePath(p0: Path.FillType?) {
|
||||
val strokeColor = graphicsState.strokingColor.toRGBHex()
|
||||
val fillColor = graphicsState.nonStrokingColor.toRGBHex()
|
||||
val lineWidth = graphicsState.lineWidth
|
||||
val rule = if (p0 == Path.FillType.EVEN_ODD) "evenodd" else "nonzero"
|
||||
if (currentPath.isNotEmpty()) {
|
||||
svgBuilder.append("<path d=\"$currentPath\" fill=\"$fillColor\" fill-rule=\"$rule\" stroke=\"$strokeColor\" stroke-width=\"$lineWidth\" />\n")
|
||||
_lines.addAll(currentSubPathLines)
|
||||
}
|
||||
currentPath.clear()
|
||||
currentSubPathLines.clear()
|
||||
}
|
||||
|
||||
override fun shadingFill(p0: COSName?) {
|
||||
currentPath.clear()
|
||||
currentSubPathLines.clear()
|
||||
}
|
||||
|
||||
private fun PDColor.toRGBHex(): String {
|
||||
return try {
|
||||
val rgb = this.toRGB()
|
||||
String.format(Locale.US, "#%06X", rgb and 0xFFFFFF)
|
||||
} catch (_: Exception) {
|
||||
"#000000"
|
||||
}
|
||||
}
|
||||
|
||||
override fun drawImage(p0: PDImage?) {}
|
||||
override fun clip(p0: Path.FillType?) {}
|
||||
|
||||
private fun tx(x: Float): Float {
|
||||
return x
|
||||
}
|
||||
private fun ty(y: Float): Float {
|
||||
return page.mediaBox.height - y
|
||||
}
|
||||
private fun t(p: PointF): PointF {
|
||||
return PointF(tx(p.x), ty(p.y))
|
||||
}
|
||||
|
||||
/**
|
||||
* Представляет страницу как набор колонок с ячейками
|
||||
*
|
||||
* Сначала делит страницу на столбики, затем, изолированно для каждого столбика делит его на ячейки
|
||||
*/
|
||||
fun asTable(): List<TableColumn> {
|
||||
val verticalLines = _lines.filter { it.x1 == it.x2 }
|
||||
val horizontalLines = _lines.filter { it.y1 == it.y2 }
|
||||
|
||||
// Список уникальных "x", чтобы разделить весь лист на колонки
|
||||
val xs = merge(verticalLines.map { it.x1 })
|
||||
|
||||
for (v in horizontalLines) {
|
||||
tableSvg.append("<rect fill='#0000ff55' x='${v.left}' y='${v.top}' width='${v.right-v.left}' height='3'/>\n")
|
||||
}
|
||||
|
||||
val columns = mutableListOf<TableColumn>()
|
||||
for (i in 0 until xs.size - 1) {
|
||||
val left = xs[i]
|
||||
val right = xs[i + 1]
|
||||
|
||||
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f00" else "#0f0"}' x='$left' y='3' width='${right-left}' height='3'/>\n")
|
||||
|
||||
val colWidth = right - left
|
||||
if (colWidth <= 2f) continue
|
||||
|
||||
// Выборка горизонтальных линиий которые пересекают колонку
|
||||
val linesInColumn = horizontalLines.filter { line -> (line.left-2f <= left && right <= line.right+2f) }
|
||||
|
||||
val ys = merge(linesInColumn.map { it.y1 }) // Уникальные "y", чтобы разделить уже столбик
|
||||
|
||||
val cells = mutableListOf<TableCell>()
|
||||
for (ii in 0 until ys.size - 1) {
|
||||
val top = ys[ii]
|
||||
val bottom = ys[ii+1]
|
||||
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")
|
||||
}
|
||||
columns.add(TableColumn(left, right-left, cells))
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает дубликаты, схлопывает близкие точки
|
||||
*/
|
||||
private fun merge(fs: List<Float>): List<Float> {
|
||||
val s = fs.distinct().sorted()
|
||||
val result = mutableListOf<Float>()
|
||||
if(s.isEmpty()) return result
|
||||
result.add(s[0])
|
||||
var pad = 0f
|
||||
for (i in 0 until s.size - 1) {
|
||||
val dst = s[i + 1] - s[i]
|
||||
if (dst <= 4f) {
|
||||
pad = dst/2f
|
||||
result[result.size-1] = result.last() + pad
|
||||
continue
|
||||
}
|
||||
result.add(s[i+1]+pad)
|
||||
pad = 0f
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package agzam.vyatsu.data.remote.pdf
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.Lesson
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import android.util.Log
|
||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Парсинг расписания из PDF
|
||||
* @author Agzam
|
||||
*/
|
||||
object PdfScheduleParser {
|
||||
|
||||
private const val TAG = "PDF-SCHEDULE"
|
||||
private const val SVG_TAG = "PDF-SVG"
|
||||
|
||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm")
|
||||
private val DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yy")
|
||||
|
||||
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() }
|
||||
|
||||
fun parse(pdfBytes: ByteArray): List<Day> {
|
||||
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
||||
val doc = try {
|
||||
PDDocument.load(pdfBytes)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "PDDocument.load failed", t)
|
||||
throw t
|
||||
}
|
||||
|
||||
val days = mutableListOf<Day>()
|
||||
try {
|
||||
for (pageIndex in 0 until doc.numberOfPages) {
|
||||
Log.v(TAG, "============ Page $pageIndex ============")
|
||||
|
||||
// Парсинг ячеек таблицы
|
||||
val page = doc.getPage(pageIndex)
|
||||
val svg = Pdf2Table(page)
|
||||
svg.processPage(page)
|
||||
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")
|
||||
|
||||
// Парсинг текста
|
||||
val textStripper = PdfTextParser()
|
||||
textStripper.startPage = pageIndex + 1
|
||||
textStripper.endPage = pageIndex + 1
|
||||
textStripper.getText(doc)
|
||||
val texts = textStripper.blocks
|
||||
|
||||
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
|
||||
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
|
||||
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
|
||||
|
||||
Log.v(TAG, "Days: ${cols[0].cells.size}")
|
||||
Log.v(TAG, "Times: ${cols[1].cells.size}")
|
||||
Log.v(TAG, "Types: ${cols[2].cells.size}")
|
||||
|
||||
/** Мерджит ячейки в одну строку, пробел в качестве слиятиля */
|
||||
fun join(texts: List<PdfTextParser.TextBlock>): String {
|
||||
val text = StringBuilder()
|
||||
for (t in texts) {
|
||||
if(!text.isEmpty()) text.append(' ')
|
||||
text.append(t.text)
|
||||
}
|
||||
return text.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает контент из PDF внутри ячейки
|
||||
* (Подразумевается что в ячейке нет "\n", поэтому используется пробел для слияния)
|
||||
*/
|
||||
fun content(cell: Pdf2Table.TableCell): String {
|
||||
return join(texts.filter {
|
||||
cell.x <= it.x && it.x <= cell.x + cell.width && cell.y <= it.y && it.y < cell.y + cell.height
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает все ячейки в целевой колонке, которые находятся на одном уровне (строке) с заданной ячейкой
|
||||
* @param column - целевая колонка, в которой производится поиск ячеек.
|
||||
* @param cell - ячейка-ориентир, определяющая границы строки по вертикали.
|
||||
* @return Список ячеек из [column], попавших в диапазон строки
|
||||
*/
|
||||
fun select(column: Pdf2Table.TableColumn, cell: Pdf2Table.TableCell): List<Pdf2Table.TableCell> {
|
||||
return column.cells.filter {
|
||||
cell.y <= it.centerY && it.centerY < cell.y + cell.height
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (dayCell in daysColumn.cells) {
|
||||
val day = content(dayCell).split(' ')
|
||||
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
||||
|
||||
val date = dateFrom(day[1]) ?: continue
|
||||
val intervals = mutableListOf<TimeSlot>()
|
||||
// TODO: день недели (а нужен ли?)
|
||||
|
||||
Log.v(TAG, "############################## $day ############################## ")
|
||||
for(intervalCell in select(intervalsColumn, dayCell)) { // День содержит интервалы
|
||||
val timeContent = content(intervalCell)
|
||||
val range = timeContent.split('-')
|
||||
if(range.size != 2) continue // XXX: выбрасывать ошибку?
|
||||
|
||||
val start = timeFrom(range[0]) ?: continue
|
||||
val end = timeFrom(range[1]) ?: continue
|
||||
val lessons = mutableListOf<Lesson>()
|
||||
|
||||
Log.v(TAG, "=== $timeContent ===")
|
||||
for(lessonCell in select(lessonsColumn, intervalCell)) { // Информация о паре, может быть разбита, например на подгруппы
|
||||
val content = content(lessonCell)
|
||||
Log.v(TAG, "- $content")
|
||||
// TODO: парсинг контента лекции
|
||||
lessons.add(Lesson(raw=content))
|
||||
}
|
||||
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
|
||||
}
|
||||
days.add(Day(date=date, intervals=intervals))
|
||||
}
|
||||
for (text in texts.filter { it.rotation != 0f }) {
|
||||
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Parsing error", e)
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
doc.close()
|
||||
}
|
||||
Log.v(TAG, "Days extracted: $days")
|
||||
return days
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package agzam.vyatsu.data.remote.pdf
|
||||
|
||||
import com.tom_roush.pdfbox.text.PDFTextStripper
|
||||
import com.tom_roush.pdfbox.text.TextPosition
|
||||
|
||||
/**
|
||||
* Парсер блоков текста из PDF
|
||||
* @author Agzam
|
||||
*/
|
||||
class PdfTextParser : PDFTextStripper() {
|
||||
|
||||
data class TextBlock(
|
||||
val text: String,
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val width: Float,
|
||||
val height: Float,
|
||||
val rotation: Float
|
||||
)
|
||||
|
||||
val blocks = mutableListOf<TextBlock>()
|
||||
|
||||
init {
|
||||
sortByPosition = true
|
||||
}
|
||||
|
||||
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
|
||||
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
|
||||
val first = textPositions.first()
|
||||
val last = textPositions.last()
|
||||
|
||||
val matrix = first.textMatrix
|
||||
val rotation = first.dir
|
||||
|
||||
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
|
||||
|
||||
val x = matrix.translateX
|
||||
val y = matrix.translateY - first.heightDir
|
||||
|
||||
val height = first.heightDir
|
||||
|
||||
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package agzam.vyatsu.data.repository
|
||||
|
||||
import agzam.vyatsu.data.local.GroupDao
|
||||
import agzam.vyatsu.data.local.GroupEntity
|
||||
import agzam.vyatsu.data.remote.GroupsParser
|
||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||
import agzam.vyatsu.data.remote.VyatsuUrls
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class GroupRepositoryImpl(
|
||||
private val groupDao: GroupDao,
|
||||
) : GroupRepository {
|
||||
|
||||
override fun observeGroups(): Flow<List<Group>> =
|
||||
groupDao.observeAll().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override suspend fun getGroups(): List<Group> =
|
||||
groupDao.observeAll().first().map { it.toDomain() }
|
||||
|
||||
override suspend fun getFaculties(): List<String> = groupDao.getFaculties()
|
||||
|
||||
override suspend fun refreshIfStale(): Boolean {
|
||||
val last = groupDao.maxSyncedAt()
|
||||
if (last != null && now() - last < STALE_WINDOW_MS) {
|
||||
return true
|
||||
}
|
||||
refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun forceRefresh(): Boolean {
|
||||
refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
val html = VyatsuHttpClient.getText(VyatsuUrls.GROUPS_PAGE)
|
||||
val groups = GroupsParser.parseGroups(html)
|
||||
val ranges = GroupsParser.parseDateRanges(html)
|
||||
val entities = groups.map { group ->
|
||||
val range = ranges[group.id]
|
||||
val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
|
||||
val spring = range?.get(Season.SPRING)?.lastOrNull()
|
||||
GroupEntity(
|
||||
id = group.id,
|
||||
name = group.name,
|
||||
faculty = group.faculty,
|
||||
autumnFirst = autumn?.first ?: "",
|
||||
autumnSecond = autumn?.second ?: "",
|
||||
springFirst = spring?.first ?: "",
|
||||
springSecond = spring?.second ?: "",
|
||||
lastSyncedAt = now()
|
||||
)
|
||||
}
|
||||
groupDao.insertAll(entities)
|
||||
}
|
||||
|
||||
private fun now(): Long = System.currentTimeMillis()
|
||||
|
||||
private fun GroupEntity.toDomain(): Group = Group(
|
||||
id = id,
|
||||
name = name,
|
||||
faculty = faculty
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package agzam.vyatsu.data.repository
|
||||
|
||||
import agzam.vyatsu.data.local.PreferencesDao
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
|
||||
class PreferencesRepositoryImpl(
|
||||
private val dao: PreferencesDao
|
||||
) : PreferencesRepository {
|
||||
override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
|
||||
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
|
||||
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package agzam.vyatsu.data.repository
|
||||
|
||||
import agzam.vyatsu.data.local.ScheduleCodec
|
||||
import agzam.vyatsu.data.local.ScheduleDao
|
||||
import agzam.vyatsu.data.local.ScheduleEntity
|
||||
import agzam.vyatsu.data.local.GroupDao
|
||||
import agzam.vyatsu.data.local.GroupEntity
|
||||
import agzam.vyatsu.data.remote.pdf.PdfScheduleParser
|
||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||
import agzam.vyatsu.data.remote.VyatsuUrls
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Schedule
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class ScheduleRepositoryImpl(
|
||||
private val scheduleDao: ScheduleDao,
|
||||
private val groupDao: GroupDao,
|
||||
private val codec: ScheduleCodec
|
||||
) : ScheduleRepository {
|
||||
|
||||
override fun observeSchedule(groupId: String): Flow<Schedule?> =
|
||||
scheduleDao.observe(groupId).map { it?.toDomain() }
|
||||
|
||||
override suspend fun getSchedule(groupId: String): Schedule? =
|
||||
scheduleDao.get(groupId)?.toDomain()
|
||||
|
||||
override suspend fun sync(groupId: String): Schedule? {
|
||||
Log.d(TAG, "sync(groupId=$groupId) start")
|
||||
val group = groupDao.getById(groupId)
|
||||
if (group == null) {
|
||||
Log.w(TAG, "sync: group $groupId not found in DB")
|
||||
return null
|
||||
}
|
||||
val season = ScheduleDates.currentSeason()
|
||||
val range = dateRangeFor(group, season)
|
||||
if (range == null) {
|
||||
Log.w(TAG, "sync: no date range for season $season (group ${group.id}, " +
|
||||
"autumn=[${group.autumnFirst}, ${group.autumnSecond}], " +
|
||||
"spring=[${group.springFirst}, ${group.springSecond}])")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
|
||||
|
||||
return try {
|
||||
val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range)
|
||||
Log.d(TAG, "sync: downloading $pdfUrl")
|
||||
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
|
||||
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
|
||||
|
||||
val days = PdfScheduleParser.parse(bytes)
|
||||
Log.d(TAG, "sync: parsed ${days.size} days")
|
||||
|
||||
val entity = ScheduleEntity(
|
||||
groupId = groupId,
|
||||
season = season.name,
|
||||
dateRangeFirst = range.first,
|
||||
dateRangeSecond = range.second,
|
||||
weeksJson = codec.toJson(days),
|
||||
lastSyncedAt = now()
|
||||
)
|
||||
scheduleDao.upsert(entity)
|
||||
Log.d(TAG, "sync: upserted schedule for $groupId")
|
||||
entity.toDomain()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun isStale(groupId: String): Boolean {
|
||||
val entity = scheduleDao.get(groupId) ?: return true
|
||||
return now() - entity.lastSyncedAt > STALE_WINDOW_MS
|
||||
}
|
||||
|
||||
override suspend fun clear(groupId: String) = scheduleDao.delete(groupId)
|
||||
|
||||
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
|
||||
return when (season) {
|
||||
Season.AUTUMN -> if (group.autumnFirst.isNotEmpty() && group.autumnSecond.isNotEmpty()) {
|
||||
DateRange(group.autumnFirst, group.autumnSecond)
|
||||
} else null
|
||||
Season.SPRING -> if (group.springFirst.isNotEmpty() && group.springSecond.isNotEmpty()) {
|
||||
DateRange(group.springFirst, group.springSecond)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun now(): Long = System.currentTimeMillis()
|
||||
|
||||
private fun ScheduleEntity.toDomain(): Schedule = Schedule(
|
||||
groupId = groupId,
|
||||
season = Season.valueOf(season),
|
||||
days = codec.fromJson(weeksJson)
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
||||
private const val TAG = "ScheduleRepo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package agzam.vyatsu.data.static
|
||||
|
||||
data class Building(
|
||||
val number: Int,
|
||||
val address: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double
|
||||
)
|
||||
|
||||
val BUILDINGS: List<Building> = listOf(
|
||||
Building(1, "ул. Московская, д. 36", 58.602692, 49.666638),
|
||||
Building(2, "ул. Московская, д. 39", 58.604297, 49.665926),
|
||||
Building(3, "ул. Московская, д. 29", 58.604258, 49.673772),
|
||||
Building(4, "ул. К.Либкнехта, д. 76 (1 корпус)", 58.602210, 49.665506),
|
||||
Building(5, "ул. Карла Маркса, д. 77", 58.599995, 49.667861),
|
||||
Building(6, "Студенческий проезд, д. 9", 58.605119, 49.618132),
|
||||
Building(7, "ул. Преображенская, д. 32", 58.605315, 49.673662),
|
||||
Building(8, "Студенческий проезд, д. 11", 58.604574, 49.618105),
|
||||
Building(9, "Студенческий проезд, д. 11а", 58.604417, 49.617226),
|
||||
Building(10, "ул. Ломоносова, д. 18а", 58.604644, 49.615911),
|
||||
Building(11, "ул. Преображенская, д. 41", 58.605713, 49.670755),
|
||||
Building(12, "ул. Ленина, д. 127", 58.586882, 49.681758),
|
||||
Building(13, "ул. Красноармейская, д. 26", 58.591304, 49.676543),
|
||||
Building(14, "ул. Ленина, д. 111", 58.591637, 49.680732),
|
||||
Building(15, "ул. Ленина, д. 198", 58.567400, 49.686333),
|
||||
Building(16, "ул. Свободы, д. 122", 58.591296, 49.678238),
|
||||
Building(17, "ул. Ленина, д. 111а", 58.591030, 49.681094),
|
||||
Building(18, "ул. Молодой Гвардии, д. 13", 58.598915, 49.687212),
|
||||
Building(19, "ул. Орловская, д. 12", 58.596234, 49.687395),
|
||||
Building(20, "ул. Ленина, д. 113", 58.590819, 49.681423),
|
||||
Building(21, "ул. Ленина, д. 111а", 58.591030, 49.681094),
|
||||
Building(22, "ул. Ленина, д. 111а", 58.591030, 49.681094),
|
||||
Building(23, "ул. Володарского, д. 2", 58.616236, 49.671680)
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package agzam.vyatsu.data.static
|
||||
|
||||
data class CallTime(val start: String, val end: String)
|
||||
|
||||
val CALLS: List<CallTime> = listOf(
|
||||
CallTime("8:20", "9:50"),
|
||||
CallTime("10:00", "11:30"),
|
||||
CallTime("11:45", "13:15"),
|
||||
CallTime("14:00", "15:30"),
|
||||
CallTime("15:45", "17:15"),
|
||||
CallTime("17:20", "18:50"),
|
||||
CallTime("18:55", "20:25")
|
||||
)
|
||||
Reference in New Issue
Block a user