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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.ksp) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
# AGP 9 built-in Kotlin: allow KSP (Room/Moshi) to register generated source sets
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
|
|
||||||
|
systemProp.sun.net.client.defaultConnectTimeout=5000
|
||||||
|
systemProp.sun.net.client.defaultReadTimeout=5000
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||||
|
toolchainVersion=21
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.1.1"
|
||||||
|
coreKtx = "1.10.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.1.5"
|
||||||
|
espressoCore = "3.5.1"
|
||||||
|
lifecycleRuntimeKtx = "2.6.1"
|
||||||
|
activityCompose = "1.8.0"
|
||||||
|
kotlin = "2.2.10"
|
||||||
|
composeBom = "2026.02.01"
|
||||||
|
ksp = "2.2.10-2.0.2"
|
||||||
|
room = "2.8.4"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
jsoup = "1.23.2"
|
||||||
|
moshi = "1.15.2"
|
||||||
|
pdfbox = "2.0.27.0"
|
||||||
|
coroutines = "1.8.1"
|
||||||
|
navigationCompose = "2.8.0"
|
||||||
|
monitor = "1.8.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
# Room
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
|
# Networking
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
|
||||||
|
# HTML parsing
|
||||||
|
jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
|
||||||
|
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
|
||||||
|
moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
|
||||||
|
|
||||||
|
# PDF
|
||||||
|
pdfbox-android = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox" }
|
||||||
|
androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#Mon Aug 31 18:35:43 MSK 2026
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "VyatsuSchedule"
|
||||||
|
include(":app")
|
||||||