init
@@ -0,0 +1,45 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.di.AppContainer
|
||||
import agzam.vyatsu.ui.groups.GroupPickerScreen
|
||||
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
||||
import agzam.vyatsu.ui.home.HomeScreen
|
||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@Composable
|
||||
fun AppRoot() {
|
||||
val app = LocalContext.current.applicationContext as VyatsuApplication
|
||||
val container = app.container
|
||||
|
||||
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||
|
||||
val state by scheduleViewModel.uiState.collectAsState()
|
||||
|
||||
if (state.noGroup) {
|
||||
GroupPickerScreen(
|
||||
viewModel = groupPickerViewModel,
|
||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||
)
|
||||
return
|
||||
}
|
||||
HomeScreen(
|
||||
scheduleViewModel = scheduleViewModel,
|
||||
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
||||
)
|
||||
}
|
||||
|
||||
private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
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 groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||
@@ -0,0 +1,19 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.ui.theme.VyatsuScheduleTheme
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
VyatsuScheduleTheme {
|
||||
AppRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.di.AppContainer
|
||||
import android.app.Application
|
||||
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
||||
|
||||
class VyatsuApplication : Application() {
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
PDFBoxResourceLoader.init(this)
|
||||
container = AppContainer(this)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package agzam.vyatsu.di
|
||||
|
||||
import agzam.vyatsu.data.local.AppDatabase
|
||||
import agzam.vyatsu.data.local.ScheduleCodec
|
||||
import agzam.vyatsu.data.remote.GroupsParser
|
||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||
import agzam.vyatsu.data.repository.GroupRepositoryImpl
|
||||
import agzam.vyatsu.data.repository.PreferencesRepositoryImpl
|
||||
import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.squareup.moshi.Moshi
|
||||
|
||||
class AppContainer(context: Context) {
|
||||
private val moshi: Moshi = Moshi.Builder().build()
|
||||
|
||||
private val database: AppDatabase = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"vyatsu_schedule.db"
|
||||
).build()
|
||||
|
||||
private val codec: ScheduleCodec = ScheduleCodec(moshi)
|
||||
|
||||
val groupRepository: GroupRepository by lazy {
|
||||
GroupRepositoryImpl(
|
||||
groupDao = database.groupDao(),
|
||||
)
|
||||
}
|
||||
|
||||
val scheduleRepository: ScheduleRepository by lazy {
|
||||
ScheduleRepositoryImpl(
|
||||
scheduleDao = database.scheduleDao(),
|
||||
groupDao = database.groupDao(),
|
||||
codec = codec
|
||||
)
|
||||
}
|
||||
|
||||
val preferencesRepository: PreferencesRepository by lazy {
|
||||
PreferencesRepositoryImpl(database.preferencesDao())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package agzam.vyatsu.domain
|
||||
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
object ScheduleDates {
|
||||
private val zone: ZoneId = ZoneId.of("Europe/Moscow")
|
||||
private val format: DateTimeFormatter = DateTimeFormatter.ofPattern("ddMMyyyy")
|
||||
|
||||
fun moscowToday(): LocalDate = LocalDate.now(zone)
|
||||
|
||||
fun asDate(dateStr: String): LocalDate = LocalDate.parse(dateStr, format)
|
||||
|
||||
fun dayIndexes(firstDate: String, today: LocalDate = moscowToday()): Pair<Int, Int> {
|
||||
val begin = asDate(firstDate)
|
||||
val anchor = begin.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY))
|
||||
val diff = ChronoUnit.DAYS.between(anchor, today)
|
||||
|
||||
var week = Math.floorMod(diff / 7, 2)
|
||||
var day = Math.floorMod(diff, 7)
|
||||
|
||||
if (day == 6) {
|
||||
day = (day + 1) % 7
|
||||
week = (week + 1) % 2
|
||||
}
|
||||
return week to day
|
||||
}
|
||||
|
||||
fun dateByIndexes(firstDate: String, week: Int, day: Int): LocalDate =
|
||||
asDate(firstDate).plusDays((week * 7 + day).toLong())
|
||||
|
||||
fun currentSeason(today: LocalDate = moscowToday()): Season =
|
||||
if (today.monthValue in 8..12) Season.AUTUMN else Season.SPRING
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
data class DateRange(
|
||||
val first: String,
|
||||
val second: String
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* День
|
||||
* @author Agzam
|
||||
*/
|
||||
data class Day(
|
||||
|
||||
/** Дата, парсится из формата dd.mm.yy из PDF **/
|
||||
val date: LocalDate,
|
||||
/** Список пар **/
|
||||
val intervals: List<TimeSlot>,
|
||||
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
/**
|
||||
* Группа студентов ВятГУ.
|
||||
* id — идентификатор группы из data-grp_period_id без последней цифры (как в PDF URL).
|
||||
*/
|
||||
data class Group(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val faculty: String
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
/**
|
||||
* Занятие
|
||||
* @author Agzam
|
||||
*/
|
||||
data class Lesson(
|
||||
|
||||
/** Сырое значение из PDF **/
|
||||
val raw: String,
|
||||
|
||||
/** Кабинет **/
|
||||
val room: String? = null,
|
||||
|
||||
/** Группа, например ИВТб-3304-05-00 **/
|
||||
val group: String? = null,
|
||||
/** Подгруппа, например на физкультуре **/
|
||||
val subgroup: String? = null,
|
||||
|
||||
/** Предмет **/
|
||||
val subject: String? = null,
|
||||
|
||||
/** Тип, например лекция/практика **/
|
||||
val type: LessonType = LessonType.OTHER,
|
||||
|
||||
/** Преподаватель **/
|
||||
val teacher: String? = null,
|
||||
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
enum class LessonType(val label: String) {
|
||||
|
||||
LECTURE("Лекция"),
|
||||
PRACTICE("Практическое занятие"),
|
||||
LABORATORY("Лабораторная работа"),
|
||||
OTHER("Занятие");
|
||||
|
||||
companion object {
|
||||
fun fromRaw(raw: String): LessonType = when (raw.trim()) {
|
||||
"Лекция" -> LECTURE
|
||||
"Практическое занятие" -> PRACTICE
|
||||
"Лабораторная работа" -> LABORATORY
|
||||
else -> OTHER
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
/**
|
||||
* Расписание группы, как правило содержит 2 недели
|
||||
* @author Agzam
|
||||
*/
|
||||
data class Schedule(
|
||||
|
||||
val groupId: String,
|
||||
val season: Season,
|
||||
|
||||
/** Может содержать более 7 дней */
|
||||
val days: List<Day>
|
||||
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
enum class Season {
|
||||
AUTUMN, SPRING;
|
||||
|
||||
val apiKey: String
|
||||
get() = when (this) {
|
||||
AUTUMN -> "1"
|
||||
SPRING -> "2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
import java.time.LocalTime
|
||||
|
||||
/**
|
||||
* Временной промежуток/пара (например 08:20-09:30 из PDF)
|
||||
*
|
||||
* Может содержать несколько [Lesson], например для разделения на подгруппы
|
||||
* @author Agzam
|
||||
*/
|
||||
data class TimeSlot(
|
||||
|
||||
val start: LocalTime,
|
||||
val end: LocalTime,
|
||||
val lessons: List<Lesson>
|
||||
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package agzam.vyatsu.domain.repository
|
||||
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface GroupRepository {
|
||||
fun observeGroups(): Flow<List<Group>>
|
||||
suspend fun getGroups(): List<Group>
|
||||
suspend fun getFaculties(): List<String>
|
||||
suspend fun refreshIfStale(): Boolean
|
||||
suspend fun forceRefresh(): Boolean
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package agzam.vyatsu.domain.repository
|
||||
|
||||
interface PreferencesRepository {
|
||||
suspend fun selectedGroupId(): String?
|
||||
suspend fun setSelectedGroup(groupId: String)
|
||||
suspend fun clearSelectedGroup()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package agzam.vyatsu.domain.repository
|
||||
|
||||
import agzam.vyatsu.domain.model.Schedule
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ScheduleRepository {
|
||||
fun observeSchedule(groupId: String): Flow<Schedule?>
|
||||
suspend fun getSchedule(groupId: String): Schedule?
|
||||
suspend fun sync(groupId: String): Schedule?
|
||||
suspend fun isStale(groupId: String): Boolean
|
||||
suspend fun clear(groupId: String)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package agzam.vyatsu.ui.groups
|
||||
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Страница выбора группы
|
||||
* @author Agzam
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupPickerScreen(viewModel: GroupPickerViewModel, onGroupPicked: (Group) -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
if (!isSearchActive) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Выберите группу",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading && state.groups.isEmpty()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
if (state.error != null && state.groups.isEmpty()) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(state.error.orEmpty(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = { viewModel.retry() }) { Text("Повторить") }
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = state.searchQuery,
|
||||
onQueryChange = { viewModel.search(it) },
|
||||
onSearch = { isSearchActive = false },
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
placeholder = { Text("Поиск группы") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (isSearchActive && state.searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.search("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
GroupList(
|
||||
groups = state.filteredGroups,
|
||||
onPick = { group ->
|
||||
isSearchActive = false
|
||||
onGroupPicked(group)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSearchActive) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
GroupList(state.filteredGroups, onGroupPicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(groups) { group ->
|
||||
Column(modifier = Modifier.fillMaxWidth() .clickable { onPick(group) }) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||
// XXX: стоит ли добавлять факультет или это засорит интерфейс
|
||||
// Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
|
||||
// if (group.faculty.isNotEmpty()) Text(text = group.faculty + " · ", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
// }
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package agzam.vyatsu.ui.groups
|
||||
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* @author Agzam
|
||||
*/
|
||||
class GroupPickerViewModel(private val repository: GroupRepository) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(GroupPickerUiState())
|
||||
val uiState: StateFlow<GroupPickerUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val ok = try {
|
||||
repository.refreshIfStale()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось загрузить группы"
|
||||
)
|
||||
false
|
||||
}
|
||||
if (!ok && _uiState.value.groups.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = _uiState.value.error ?: "Не удалось обновить список групп"
|
||||
)
|
||||
}
|
||||
}
|
||||
repository.observeGroups().collect { groups ->
|
||||
val query = _uiState.value.searchQuery
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
groups = groups,
|
||||
filteredGroups = filterGroups(groups, query),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
val groups = _uiState.value.groups
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchQuery = query,
|
||||
filteredGroups = filterGroups(groups, query)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
|
||||
if (query.isBlank()) return groups
|
||||
val lowerQuery = query.lowercase()
|
||||
return groups.filter { group ->
|
||||
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
load()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
repository.forceRefresh()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Не удалось обновить список групп"
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class GroupPickerUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val groups: List<Group> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val filteredGroups: List<Group> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package agzam.vyatsu.ui.home
|
||||
|
||||
import agzam.vyatsu.ui.info.BuildingsScreen
|
||||
import agzam.vyatsu.ui.info.CallsScreen
|
||||
import agzam.vyatsu.ui.schedule.ScheduleScreen
|
||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
private enum class HomeTab(val label: String) {
|
||||
SCHEDULE("Расписание"),
|
||||
CALLS("Звонки"),
|
||||
BUILDINGS("Корпуса")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
scheduleViewModel: ScheduleViewModel,
|
||||
onSwitchGroup: () -> Unit
|
||||
) {
|
||||
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
HomeTab.entries.forEach { t ->
|
||||
NavigationBarItem(
|
||||
selected = tab == t,
|
||||
onClick = { tab = t },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = when (t) {
|
||||
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
||||
HomeTab.CALLS -> Icons.Default.Info
|
||||
HomeTab.BUILDINGS -> Icons.Default.Home
|
||||
},
|
||||
contentDescription = t.label
|
||||
)
|
||||
},
|
||||
label = { Text(t.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (tab) {
|
||||
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||
HomeTab.CALLS -> CallsScreen()
|
||||
HomeTab.BUILDINGS -> BuildingsScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package agzam.vyatsu.ui.info
|
||||
|
||||
import agzam.vyatsu.data.static.BUILDINGS
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import agzam.vyatsu.BuildConfig
|
||||
|
||||
@Composable
|
||||
fun BuildingsScreen() {
|
||||
val context = LocalContext.current
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
items(BUILDINGS) { b ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val uri = Uri.parse("geo:${b.latitude},${b.longitude}?q=${b.latitude},${b.longitude}(Корпус ${b.number})")
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Корпус ${b.number}",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
b.address,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
"Версия ${BuildConfig.VERSION_NAME}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package agzam.vyatsu.ui.info
|
||||
|
||||
import agzam.vyatsu.data.static.CALLS
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun CallsScreen() {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
itemsIndexed(CALLS) { index, call ->
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(
|
||||
"${index + 1} пара",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
"${call.start} – ${call.end}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package agzam.vyatsu.ui.schedule
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text("Расписание") },
|
||||
actions = {
|
||||
TextButton(onClick = { onSwitchGroup() }) {
|
||||
Text("Сменить группу")
|
||||
}
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
|
||||
)
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
||||
when {
|
||||
state.isSyncing && state.schedule == null -> {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
"Не удалось загрузить расписание",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
TextButton(onClick = { viewModel.refresh() }) {
|
||||
Text("Повторить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val schedule = state.schedule!!
|
||||
val day = schedule.days.getOrNull(state.selectedIndex)
|
||||
if (day == null) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
val isToday = state.selectedIndex == state.todayIndex
|
||||
val dayLabel = if (isToday) "Сегодня " else ""
|
||||
|
||||
Text(
|
||||
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
DayChips(
|
||||
days = schedule.days,
|
||||
selectedIndex = state.selectedIndex,
|
||||
todayIndex = state.todayIndex,
|
||||
onSelect = viewModel::selectDay
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayChips(
|
||||
days: List<Day>,
|
||||
selectedIndex: Int,
|
||||
todayIndex: Int,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
itemsIndexed(days) { index, day ->
|
||||
val selected = selectedIndex == index
|
||||
val isTodayChip = todayIndex == index
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onSelect(index) },
|
||||
label = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
dayName(day.date.dayOfWeek),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Text(
|
||||
day.date.format(CHIP_DATE),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
border = if (isTodayChip && !selected) {
|
||||
FilterChipDefaults.filterChipBorder(
|
||||
borderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 1.dp,
|
||||
enabled = true,
|
||||
selected = false
|
||||
)
|
||||
} else {
|
||||
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (slot.lessons.isEmpty()) {
|
||||
Text("—", style = MaterialTheme.typography.bodyMedium)
|
||||
} else {
|
||||
slot.lessons.forEach { lesson ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
listOf(lesson.type.label, lesson.subgroup, lesson.teacher, lesson.room)
|
||||
.filterNotNull()
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
||||
DayOfWeek.MONDAY -> "Пн"
|
||||
DayOfWeek.TUESDAY -> "Вт"
|
||||
DayOfWeek.WEDNESDAY -> "Ср"
|
||||
DayOfWeek.THURSDAY -> "Чт"
|
||||
DayOfWeek.FRIDAY -> "Пт"
|
||||
DayOfWeek.SATURDAY -> "Сб"
|
||||
DayOfWeek.SUNDAY -> "Вс"
|
||||
}
|
||||
|
||||
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
@@ -0,0 +1,113 @@
|
||||
package agzam.vyatsu.ui.schedule
|
||||
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.Schedule
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ScheduleViewModel(
|
||||
private val scheduleRepository: ScheduleRepository,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var currentGroupId: String? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val groupId = preferencesRepository.selectedGroupId()
|
||||
if (groupId != null) {
|
||||
selectGroup(groupId)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectGroup(groupId: String) {
|
||||
currentGroupId = groupId
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setSelectedGroup(groupId)
|
||||
_uiState.value = _uiState.value.copy(noGroup = false)
|
||||
|
||||
launch {
|
||||
scheduleRepository.observeSchedule(groupId).collect { schedule ->
|
||||
currentGroupId?.let {
|
||||
val days = schedule?.days.orEmpty()
|
||||
val today = todayIndex(days)
|
||||
val current = _uiState.value
|
||||
val selected = current.selectedIndex.takeIf { i -> i in days.indices } ?: today
|
||||
_uiState.value = current.copy(
|
||||
schedule = schedule,
|
||||
todayIndex = today,
|
||||
selectedIndex = selected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
_uiState.value = _uiState.value.copy(isSyncing = true)
|
||||
scheduleRepository.sync(groupId)
|
||||
_uiState.value = _uiState.value.copy(isSyncing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchGroup() {
|
||||
viewModelScope.launch {
|
||||
currentGroupId = null
|
||||
preferencesRepository.clearSelectedGroup()
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val groupId = currentGroupId ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
||||
scheduleRepository.sync(groupId)
|
||||
val current = _uiState.value
|
||||
val days = current.schedule?.days.orEmpty()
|
||||
val today = todayIndex(days)
|
||||
_uiState.value = current.copy(
|
||||
isRefreshing = false,
|
||||
todayIndex = today,
|
||||
selectedIndex = current.selectedIndex.takeIf { it in days.indices } ?: today
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectDay(index: Int) {
|
||||
val days = _uiState.value.schedule?.days.orEmpty()
|
||||
if (index !in days.indices) return
|
||||
_uiState.value = _uiState.value.copy(selectedIndex = index)
|
||||
}
|
||||
|
||||
private fun todayIndex(days: List<Day>): Int {
|
||||
if (days.isEmpty()) return 0
|
||||
val today = ScheduleDates.moscowToday()
|
||||
val exact = days.indexOfFirst { it.date == today }
|
||||
if (exact >= 0) return exact
|
||||
val next = days.indexOfFirst { !it.date.isBefore(today) }
|
||||
return if (next >= 0) next else days.lastIndex
|
||||
}
|
||||
}
|
||||
|
||||
data class ScheduleUiState(
|
||||
val noGroup: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isSyncing: Boolean = false,
|
||||
val schedule: Schedule? = null,
|
||||
val todayIndex: Int = 0,
|
||||
val selectedIndex: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package agzam.vyatsu.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,58 @@
|
||||
package agzam.vyatsu.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun VyatsuScheduleTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package agzam.vyatsu.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">VyatsuSchedule</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.VyatsuSchedule" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||