ref: rewrite code architecture for Schedule

This commit is contained in:
2026-09-06 20:42:32 +03:00
parent 568ca32668
commit 008ec3030b
18 changed files with 1360 additions and 999 deletions
+37 -1
View File
@@ -1,12 +1,48 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). // Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn package ru.sweetbread.unn
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.core.network.PortalApi
import ru.sweetbread.unn.core.network.createHttpClient
import ru.sweetbread.unn.db.AppDatabase
import ru.sweetbread.unn.feature.schedule.ScheduleRepository
import splitties.arch.room.roomDb
class UNNApp : Application() { class UNNApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {
super.attachBaseContext(base) super.attachBaseContext(base)
} }
} }
class AppContainer(context: Context) {
val sessionManager = SessionManager()
val httpClient = createHttpClient()
val portalApi = PortalApi(
client = httpClient,
session = sessionManager
)
val database = roomDb<AppDatabase>(name = "cache") {
fallbackToDestructiveMigration(dropAllTables = true)
}
val scheduleRepository = ScheduleRepository(
api = portalApi,
db = database,
session = sessionManager
)
}
@@ -1,95 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.api
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import ru.sweetbread.unn.db.cacheSchedule
import ru.sweetbread.unn.db.loadSchedule
import ru.sweetbread.unn.ui.layout.LoginData
import java.time.LocalDate
lateinit var PHPSESSID: String
lateinit var CSRF: String
lateinit var ME: User
/**
* Authorize user by [login] and [password]
*
* Also defines local vars [PHPSESSID] and [ME]
*/
suspend fun auth(
login: String = LoginData.login,
password: String = LoginData.password,
forced: Boolean = false
): Boolean {
if (!forced) {
if (::PHPSESSID.isInitialized and ::ME.isInitialized)
return true
}
val id = getToken(login, password)
if (id != null) {
PHPSESSID = id
getMyself(login)
getCSRF()
return true
}
return false
}
/**
* Save info about current [User] in memory
*/
private suspend fun getMyself(login: String) = coroutineScope {
val idDeferred = async { getId(login) }
val userDeferred = async { getUser() }
val id = idDeferred.await()
val user = userDeferred.await()
ME = User(
unnId = id,
userId = user.userId,
type = Type.student,
email = user.email,
nameRu = user.nameRu,
nameEn = user.nameEn,
isMale = user.isMale,
birthday = user.birthday,
avatar = user.avatar
)
}
suspend fun getScheduleDay(
date: LocalDate,
type: Type = ME.type,
id: Int = ME.unnId!!
): List<ScheduleUnit> {
if ((type == ME.type) and (id == ME.unnId!!)) {
val schedule = withContext(Dispatchers.IO) { loadSchedule(date) }
Log.d("Schedule", schedule.joinToString())
if (schedule.isNotEmpty())
return schedule
}
return getSchedule(type, id, date, date)
}
suspend fun getSchedule(
type: Type = ME.type,
id: Int = ME.unnId!!,
start: LocalDate,
finish: LocalDate
): ArrayList<ScheduleUnit> {
val schedule = downloadSchedule(type, id, start, finish)
if ((type == ME.type) and (id == ME.unnId!!))
cacheSchedule(schedule)
return schedule
}
@@ -1,255 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.api
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import io.ktor.http.parameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import ru.sweetbread.unn.db.cacheUser
import ru.sweetbread.unn.db.loadUserByBitrixId
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
const val portalURL = "https://portal.unn.ru"
const val ruzapiURL = "$portalURL/ruzapi"
const val vuzapiURL = "$portalURL/bitrix/vuz/api"
const val prtl2URL = "$portalURL/portal2/api"
const val restURL = "$portalURL/rest"
val client = HttpClient(Android) {
install(HttpCache)
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.i("Ktor", message)
}
}
level = LogLevel.ALL
}
install(HttpTimeout) {
connectTimeoutMillis = 5000
}
install(HttpRequestRetry) {
retryOnException(maxRetries = 3, retryOnTimeout = true)
exponentialDelay()
modifyRequest { request ->
request.headers.append("x-retry-count", retryCount.toString())
}
}
}
suspend fun getToken(login: String, password: String): String? {
val r = client.submitForm("$portalURL/auth/?login=yes",
formParameters = parameters {
append("AUTH_FORM", "Y")
append("TYPE", "AUTH")
append("backurl", "/")
append("USER_LOGIN", login)
append("USER_PASSWORD", password)
}
)
if (r.status.value == 302)
return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
return null
}
suspend fun getId(login: String): Int {
return JSONObject(client.get("$ruzapiURL/studentinfo/") {
header("Cookie", "PHPSESSID=$PHPSESSID")
parameter("uns", login.drop(1))
}.bodyAsText()).getString("id").toInt()
}
suspend fun getUser(userId: Int? = null): User {
// WARNING: trailing / is important, 'cuz API devs are eating shit
// TODO: make up another way to get unnId: this is not useful for lectures
val json = JSONObject(
client.get("$vuzapiURL/user/${userId ?: ""}") {
header("Cookie", "PHPSESSID=$PHPSESSID")
}.bodyAsText()
)
return User(
unnId = null,
userId = json.getInt("id"),
type = if (json.getJSONArray("profiles").getJSONObject(0)
.getString("type") == "employee"
) Type.employee else Type.student,
email = json.getString("email"),
nameRu = json.getString("fullname"),
nameEn = json.getString("fullname_en"),
isMale = json.getString("sex") == "M",
birthday = Instant
.parse(json.getString("birthdate"))
.atZone(ZoneId.of("Europe/Moscow"))
.toLocalDate(),
avatar = json.getJSONObject("photo").let {
AvatarSet(
it.getString("orig"),
it.getString("thumbnail"),
it.getString("small"),
)
}
)
}
/**
* Fetch schedule from Uni API
*
* @return [ArrayList] of all [ScheduleUnit]s between [start] and [finish]
*/
suspend fun downloadSchedule(
type: Type = ME.type,
id: Int = ME.unnId!!,
start: LocalDate,
finish: LocalDate
): ArrayList<ScheduleUnit> {
val unnDatePattern = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val r = client.get("$ruzapiURL/schedule/${type.name}/$id") {
parameter("start", start.format(unnDatePattern))
parameter("finish", finish.format(unnDatePattern))
parameter("lng", "1")
}
val json = JSONArray(r.bodyAsText())
val out = arrayListOf<ScheduleUnit>()
for (i in 0 until json.length()) {
val unit = json.getJSONObject(i)
val lecturesJson = unit.getJSONArray("listOfLecturers")
val lecturers = arrayListOf<Lecturer>()
for (j in 0 until lecturesJson.length()) {
val lecturer = lecturesJson.getJSONObject(j)
lecturers.add(
Lecturer(
name = lecturer.getString("lecturer"),
email = lecturer.getString("lecturerEmail"),
unnId = lecturer.getInt("lecturerOid"),
uid = lecturer.getString("lecturerUID"),
rank = when (lecturer.getString("lecturer_rank")) {
"АССИСТ" -> LecturerRank.Assistant
"СТПРЕП" -> LecturerRank.SLecturer
"ДОЦЕНТ" -> LecturerRank.AProfessor
else -> LecturerRank.Lecturer
}
)
)
}
out.add(
ScheduleUnit(
oid = unit.getInt("lessonOid"),
auditorium = Auditorium(
name = unit.getString("auditorium"),
oid = unit.getInt("auditoriumOid"),
floor = unit.getInt("auditoriumfloor"),
building = Building(
name = unit.getString("building"),
gid = unit.getInt("buildingGid"),
oid = unit.getInt("buildingOid")
)
),
date = LocalDate.parse(unit.getString("date"), unnDatePattern),
discipline = Discipline(
name = unit.getString("discipline"),
oid = unit.getInt("disciplineOid"),
type = unit.getInt("disciplinetypeload")
),
kindOfWork = KindOfWork(
name = unit.getString("kindOfWork"),
complexity = unit.getInt("kindOfWorkComplexity"),
oid = unit.getInt("kindOfWorkOid"),
uid = unit.getString("kindOfWorkUid")
),
lecturers = lecturers,
stream = unit.getString("stream"),
begin = LocalTime.parse(
unit.getString("beginLesson"),
DateTimeFormatter.ofPattern("HH:mm")
),
end = LocalTime.parse(
unit.getString("endLesson"),
DateTimeFormatter.ofPattern("HH:mm")
)
)
)
}
return out
}
suspend fun getCSRF() {
val r = client.get("$restURL/log.blogpost.get") {
header("Cookie", "PHPSESSID=$PHPSESSID")
parameter("sessid", "")
}
CSRF = JSONObject(r.bodyAsText()).getString("sessid")
}
suspend fun getUserByBitrixId(id: Int): User {
withContext(Dispatchers.IO) {
loadUserByBitrixId(id)
}?.let { return it }
val userId = JSONObject(client.get("$vuzapiURL/user/bx/$id") {
header("Cookie", "PHPSESSID=$PHPSESSID")
}.bodyAsText()).getInt("id")
getUser(userId).let { user ->
withContext(Dispatchers.IO) {
cacheUser(user)
}
return user
}
}
suspend fun getBlogposts(): ArrayList<Post> {
val r = client.get("$prtl2URL/news.php") {
header("Cookie", "PHPSESSID=$PHPSESSID")
header("x-bitrix-sessid-token", CSRF)
}
val result = JSONArray(r.bodyAsText())
val out = arrayListOf<Post>()
for (i in 0 until result.length()) {
val el = result.getJSONObject(i)
out.add(
Post(
id = el.getString("id").toInt(),
authorId = el.getJSONObject("author").getInt("id").toInt(),
enableComments = true, // FIXME: Delete the field or get correct value
numComments = el.getString("commentsnum").toInt(),
date = LocalDateTime.parse(
el.getString("time"),
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss")
),
content = el.getString("fulltext")
)
)
}
return out
}
@@ -3,19 +3,10 @@
package ru.sweetbread.unn.api package ru.sweetbread.unn.api
import ru.sweetbread.unn.R import ru.sweetbread.unn.R
import splitties.resources.appStr
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
enum class Type(val s: String) {
student(appStr(R.string.student)),
group("group"),
lecturer(appStr(R.string.lecturer)),
auditorium("auditorium"),
employee(appStr(R.string.employee))
}
enum class LecturerRank(val id: Int) { enum class LecturerRank(val id: Int) {
Assistant(R.string.assistant), Assistant(R.string.assistant),
Lecturer(R.string.lecturer), Lecturer(R.string.lecturer),
@@ -32,7 +23,8 @@ class ScheduleUnit(
val lecturers: List<Lecturer>, val lecturers: List<Lecturer>,
val stream: String, val stream: String,
val begin: LocalTime, val begin: LocalTime,
val end: LocalTime val end: LocalTime,
val lastUpdated: LocalDateTime,
) )
class Auditorium( class Auditorium(
@@ -69,18 +61,6 @@ class Lecturer(
val uid: String val uid: String
) )
class User(
val unnId: Int?,
val userId: Int,
val type: Type,
val email: String,
val nameRu: String,
val nameEn: String,
val isMale: Boolean,
val birthday: LocalDate,
val avatar: AvatarSet
)
class Post( class Post(
val id: Int, val id: Int,
val authorId: Int, val authorId: Int,
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import ru.sweetbread.unn.model.User
class SessionManager {
private val _user = MutableStateFlow<User?>(null)
val user = _user.asStateFlow()
var sessionId: String? = null
private set
var csrf: String? = null
private set
var uns: Int? = null
private set
fun update(
user: User,
sessionId: String,
csrf: String,
uns: Int
) {
this._user.value = user
this.sessionId = sessionId
this.csrf = csrf
this.uns = uns
}
fun clear() {
_user.value = null
sessionId = null
csrf = null
uns = null
}
}
@@ -0,0 +1,95 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core.network
import io.ktor.client.HttpClient
import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.ui.layout.LoginData
class PortalApi (
val client: HttpClient,
val session: SessionManager
) {
public val auth = Auth(client, session)
public val ruzapi = Ruzapi(client, session)
public val bitrix = Bitrix(client, session)
/**
* Authorize user by [login] and [password]
*/
suspend fun auth(
login: String = LoginData.login,
password: String = LoginData.password,
forced: Boolean = false
): Boolean {
if (!forced and (session.sessionId != null))
return true
val id = this.auth.getToken(login, password)
if (id != null) {
session.update(
bitrix.getCurrentProfile(),
id,
this.auth.getCSRF(),
login.drop(1).toInt()
)
return true
}
return false
}
// /**
// * Save info about current [User] in memory
// */
// private suspend fun getMyself(login: String): User = coroutineScope {
// val idDeferred = async { getId(login) }
// val userDeferred = async { getUser() }
//
// val id = idDeferred.await()
// val user = userDeferred.await()
//
// return@coroutineScope User(
// bx_id = null,
// ra_id = null,
// type = Type.student,
// email = user.email,
// nameRu = user.nameRu,
// nameEn = user.nameEn,
// isMale = user.isMale,
// birthday = user.birthday,
// avatar = user.avatar
// )
// }
// suspend fun getScheduleDay(
// date: LocalDate,
// type: Type = session.user.value!!.type,
// id: Int = ME.unnId!!
// ): List<ScheduleUnit> {
// if ((type == ME.type) and (id == ME.unnId!!)) {
// val schedule = withContext(Dispatchers.IO) { loadSchedule(date) }
// Log.d("Schedule", schedule.joinToString())
// if (schedule.isNotEmpty())
// return schedule
// }
//
// return getSchedule(type, id, date, date)
// }
//
// suspend fun getSchedule(
// type: Type = ME.type,
// id: Int = ME.unnId!!,
// start: LocalDate,
// finish: LocalDate
// ): ArrayList<ScheduleUnit> {
// val schedule = downloadSchedule(type, id, start, finish)
//
// if ((type == ME.type) and (id == ME.unnId!!))
// cacheSchedule(schedule)
//
// return schedule
// }
}
@@ -0,0 +1,37 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core.network
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.cookies.HttpCookies
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
fun createHttpClient() = HttpClient(Android) {
install(HttpCache)
install(HttpCookies)
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.i("Ktor", message)
}
}
level = LogLevel.ALL
}
install(HttpTimeout) {
connectTimeoutMillis = 5000
}
install(HttpRequestRetry) {
retryOnException(maxRetries = 3, retryOnTimeout = true)
exponentialDelay()
modifyRequest { request ->
request.headers.append("x-retry-count", retryCount.toString())
}
}
}
@@ -0,0 +1,360 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core.network
import io.ktor.client.HttpClient
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import io.ktor.http.parameters
import org.json.JSONArray
import org.json.JSONObject
import ru.sweetbread.unn.api.Auditorium
import ru.sweetbread.unn.api.Building
import ru.sweetbread.unn.api.Discipline
import ru.sweetbread.unn.api.KindOfWork
import ru.sweetbread.unn.api.Lecturer
import ru.sweetbread.unn.api.LecturerRank
import ru.sweetbread.unn.api.ScheduleUnit
import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.model.User
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale.getDefault
const val portalURL = "https://portal.unn.ru"
const val ruzapiURL = "$portalURL/ruzapi"
const val vuzapiURL = "$portalURL/bitrix/vuz/api"
const val prtl2URL = "$portalURL/portal2/api"
const val restURL = "$portalURL/rest"
class Auth (
val client: HttpClient,
val session: SessionManager
) {
/**
* Get token (phpsessid header) from portal
*/
suspend fun getToken(login: String, password: String): String? {
val r = client.submitForm("$portalURL/auth/?login=yes",
formParameters = parameters {
append("AUTH_FORM", "Y")
append("TYPE", "AUTH")
append("backurl", "/")
append("USER_LOGIN", login)
append("USER_PASSWORD", password)
}
)
if (r.status.value == 302)
return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
return null
}
suspend fun getCSRF(): String {
val r = client.get("$restURL/log.blogpost.get") {
parameter("sessid", "")
}
return JSONObject(r.bodyAsText()).getString("sessid")
}
}
/**
* Bitrix addon for universities
*
* Here are schedules, students and lecturers, auditorium and building
*/
class Ruzapi(
val client: HttpClient,
val session: SessionManager
) {
val unnDatePattern: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
enum class Type {Group, Student, Person, Lecturer, Auditorium}
suspend fun search(term: String, type: Type? = null): JSONArray {
val r = client.get("$ruzapiURL/search") {
parameter("term", term)
type?.let { parameter("type", type.name.lowercase(getDefault())) }
}
return JSONArray(r)
}
/**
* Get schedule of a student by login. By default, your own
*/
suspend fun getScheduleByUns(
uns: Int = session.uns!!,
from: LocalDate = LocalDate.now(),
until: LocalDate = LocalDate.now()
): ArrayList<ScheduleUnit> {
// TODO: Check for type = student before using
// _______
val r = client.get("$ruzapiURL/schedule/student") {
parameter("uns", uns)
parameter("start", from.format(unnDatePattern))
parameter("finish", until.format(unnDatePattern))
}
return parseSchedule(r.bodyAsText())
}
private fun parseSchedule(body: String): ArrayList<ScheduleUnit> {
val json = JSONArray(body)
val out = arrayListOf<ScheduleUnit>()
for (i in 0 until json.length()) {
val unit = json.getJSONObject(i)
val lecturesJson = unit.getJSONArray("listOfLecturers")
val lecturers = arrayListOf<Lecturer>()
for (j in 0 until lecturesJson.length()) {
val lecturer = lecturesJson.getJSONObject(j)
lecturers.add(
Lecturer(
name = lecturer.getString("lecturer"),
email = lecturer.getString("lecturerEmail"),
unnId = lecturer.getInt("lecturerOid"),
uid = lecturer.getString("lecturerUID"),
rank = when (lecturer.getString("lecturer_rank")) {
"АССИСТ" -> LecturerRank.Assistant
"СТПРЕП" -> LecturerRank.SLecturer
"ДОЦЕНТ" -> LecturerRank.AProfessor
else -> LecturerRank.Lecturer
}
)
)
}
out.add(
ScheduleUnit(
oid = unit.getInt("lessonOid"),
auditorium = Auditorium(
name = unit.getString("auditorium"),
oid = unit.getInt("auditoriumOid"),
floor = unit.getInt("auditoriumfloor"),
building = Building(
name = unit.getString("building"),
gid = unit.getInt("buildingGid"),
oid = unit.getInt("buildingOid")
)
),
date = LocalDate.parse(unit.getString("date"), unnDatePattern),
discipline = Discipline(
name = unit.getString("discipline"),
oid = unit.getInt("disciplineOid"),
type = unit.getInt("disciplinetypeload")
),
kindOfWork = KindOfWork(
name = unit.getString("kindOfWork"),
complexity = unit.getInt("kindOfWorkComplexity"),
oid = unit.getInt("kindOfWorkOid"),
uid = unit.getString("kindOfWorkUid")
),
lecturers = lecturers,
stream = unit.getString("stream"),
begin = LocalTime.parse(
unit.getString("beginLesson"),
DateTimeFormatter.ofPattern("HH:mm")
),
end = LocalTime.parse(
unit.getString("endLesson"),
DateTimeFormatter.ofPattern("HH:mm")
),
LocalDateTime.now()
)
)
}
return out
}
}
class Bitrix(
val client: HttpClient,
val session: SessionManager
) {
suspend fun getCurrentProfile(): User {
val r = client.get("$vuzapiURL/profile/current")
return User(JSONObject(r.bodyAsText()))
}
}
//suspend fun getId(login: String): Int {
// return JSONObject(client.get("$ruzapiURL/studentinfo/") {
// header("Cookie", "PHPSESSID=${PHPSESSID}")
// parameter("uns", login.drop(1))
// }.bodyAsText()).getString("id").toInt()
//}
//
//suspend fun getUser(userId: Int? = null): User {
// // WARNING: trailing / is important, 'cuz API devs are eating shit
// // TODO: make up another way to get unnId: this is not useful for lectures
// val json = JSONObject(
// client.get("$vuzapiURL/user/${userId ?: ""}") {
// header("Cookie", "PHPSESSID=$PHPSESSID")
// }.bodyAsText()
// )
//
// return User(
// unnId = null,
// userId = json.getInt("id"),
// type = if (json.getJSONArray("profiles").getJSONObject(0)
// .getString("type") == "employee"
// ) Type.employee else Type.student,
// email = json.getString("email"),
// nameRu = json.getString("fullname"),
// nameEn = json.getString("fullname_en"),
// isMale = json.getString("sex") == "M",
// birthday = Instant
// .parse(json.getString("birthdate"))
// .atZone(ZoneId.of("Europe/Moscow"))
// .toLocalDate(),
// avatar = json.getJSONObject("photo").let {
// AvatarSet(
// it.getString("orig"),
// it.getString("thumbnail"),
// it.getString("small"),
// )
// }
// )
//}
//
//suspend fun getProfile
//
///**
// * Fetch schedule from Uni API
// *
// * @return [ArrayList] of all [ru.sweetbread.unn.api.ScheduleUnit]s between [start] and [finish]
// */
//suspend fun downloadSchedule(
// type: Type = ME.type,
// id: Int = ME.unnId!!,
// start: LocalDate,
// finish: LocalDate
//): ArrayList<ScheduleUnit> {
// val unnDatePattern = DateTimeFormatter.ofPattern("yyyy-MM-dd")
//
// val r = client.get("$ruzapiURL/schedule/${type.name}/$id") {
// parameter("start", start.format(unnDatePattern))
// parameter("finish", finish.format(unnDatePattern))
// parameter("lng", "1")
// }
//
// val json = JSONArray(r.bodyAsText())
// val out = arrayListOf<ScheduleUnit>()
// for (i in 0 until json.length()) {
// val unit = json.getJSONObject(i)
// val lecturesJson = unit.getJSONArray("listOfLecturers")
// val lecturers = arrayListOf<Lecturer>()
//
// for (j in 0 until lecturesJson.length()) {
// val lecturer = lecturesJson.getJSONObject(j)
// lecturers.add(
// Lecturer(
// name = lecturer.getString("lecturer"),
// email = lecturer.getString("lecturerEmail"),
// unnId = lecturer.getInt("lecturerOid"),
// uid = lecturer.getString("lecturerUID"),
// rank = when (lecturer.getString("lecturer_rank")) {
// "АССИСТ" -> LecturerRank.Assistant
// "СТПРЕП" -> LecturerRank.SLecturer
// "ДОЦЕНТ" -> LecturerRank.AProfessor
// else -> LecturerRank.Lecturer
// }
// )
// )
// }
//
// out.add(
// ScheduleUnit(
// oid = unit.getInt("lessonOid"),
// auditorium = Auditorium(
// name = unit.getString("auditorium"),
// oid = unit.getInt("auditoriumOid"),
// floor = unit.getInt("auditoriumfloor"),
// building = Building(
// name = unit.getString("building"),
// gid = unit.getInt("buildingGid"),
// oid = unit.getInt("buildingOid")
// )
// ),
// date = LocalDate.parse(unit.getString("date"), unnDatePattern),
// discipline = Discipline(
// name = unit.getString("discipline"),
// oid = unit.getInt("disciplineOid"),
// type = unit.getInt("disciplinetypeload")
// ),
// kindOfWork = KindOfWork(
// name = unit.getString("kindOfWork"),
// complexity = unit.getInt("kindOfWorkComplexity"),
// oid = unit.getInt("kindOfWorkOid"),
// uid = unit.getString("kindOfWorkUid")
// ),
// lecturers = lecturers,
// stream = unit.getString("stream"),
// begin = LocalTime.parse(
// unit.getString("beginLesson"),
// DateTimeFormatter.ofPattern("HH:mm")
// ),
// end = LocalTime.parse(
// unit.getString("endLesson"),
// DateTimeFormatter.ofPattern("HH:mm")
// )
// )
// )
// }
//
// return out
//}
//
//
//suspend fun getUserByBitrixId(id: Int): User {
// withContext(Dispatchers.IO) {
// loadUserByBitrixId(id)
// }?.let { return it }
//
// val userId = JSONObject(client.get("$vuzapiURL/user/bx/$id") {
// header("Cookie", "PHPSESSID=${PHPSESSID}")
// }.bodyAsText()).getInt("id")
//
// getUser(userId).let { user ->
// withContext(Dispatchers.IO) {
// cacheUser(user)
// }
// return user
// }
//}
//
//suspend fun getBlogposts(): ArrayList<Post> {
// val r = client.get("$prtl2URL/news.php") {
// header("Cookie", "PHPSESSID=${PHPSESSID}")
// header("x-bitrix-sessid-token", CSRF)
// }
// val result = JSONArray(r.bodyAsText())
//
// val out = arrayListOf<Post>()
// for (i in 0 until result.length()) {
// val el = result.getJSONObject(i)
// out.add(
// Post(
// id = el.getString("id").toInt(),
// authorId = el.getJSONObject("author").getInt("id").toInt(),
// enableComments = true, // FIXME: Delete the field or get correct value
// numComments = el.getString("commentsnum").toInt(),
// date = LocalDateTime.parse(
// el.getString("time"),
// DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss")
// ),
// content = el.getString("fulltext")
// )
// )
// }
// return out
//}
@@ -4,11 +4,6 @@ package ru.sweetbread.unn.db
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import splitties.arch.room.roomDb
val cacheDb = roomDb<AppDatabase>(name = "cache") {
fallbackToDestructiveMigration(dropAllTables = true)
}
@Database(entities = [ @Database(entities = [
UserDB::class, UserDB::class,
@@ -10,6 +10,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Query import androidx.room.Query
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import ru.sweetbread.unn.api.Auditorium import ru.sweetbread.unn.api.Auditorium
import ru.sweetbread.unn.api.Building import ru.sweetbread.unn.api.Building
@@ -34,7 +35,7 @@ data class BuildingDB(
@Dao @Dao
interface BuildingDao { interface BuildingDao {
@Query("SELECT * FROM buildingdb WHERE oid = :oid LIMIT 1") @Query("SELECT * FROM buildingdb WHERE oid = :oid LIMIT 1")
fun get(oid: Int): BuildingDB? suspend fun get(oid: Int): BuildingDB?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(buildings: List<BuildingDB>) suspend fun insert(buildings: List<BuildingDB>)
@@ -59,7 +60,7 @@ data class AuditoriumDB(
@Dao @Dao
interface AuditoriumDao { interface AuditoriumDao {
@Query("SELECT * FROM auditoriumdb WHERE oid = :oid LIMIT 1") @Query("SELECT * FROM auditoriumdb WHERE oid = :oid LIMIT 1")
fun get(oid: Int): AuditoriumDB? suspend fun get(oid: Int): AuditoriumDB?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(auditoriums: List<AuditoriumDB>) suspend fun insert(auditoriums: List<AuditoriumDB>)
@@ -68,12 +69,12 @@ interface AuditoriumDao {
fun delete(auditorium: AuditoriumDB) fun delete(auditorium: AuditoriumDB)
} }
fun AuditoriumDB.toModel(): Auditorium? { suspend fun AuditoriumDB.toModel(db: AppDatabase): Auditorium? {
return Auditorium( return Auditorium(
name, name,
oid, oid,
floor ?: 0, floor ?: 0,
cacheDb.buildingDao().get(buildingOid)?.toModel() ?: return null db.buildingDao().get(buildingOid)?.toModel() ?: return null
) )
} }
fun Auditorium.toEntity() = AuditoriumDB( fun Auditorium.toEntity() = AuditoriumDB(
@@ -84,14 +85,16 @@ fun Auditorium.toEntity() = AuditoriumDB(
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
) )
suspend fun cacheAuditorium(auditoriums: List<Auditorium>) { suspend fun cacheAuditorium(db: AppDatabase, auditoriums: List<Auditorium>) {
cacheDb.buildingDao().insert( db.withTransaction {
auditoriums.map { it.building }.map { it.toEntity() } db.buildingDao().insert(
) auditoriums.map { it.building }.map { it.toEntity() }
)
cacheDb.auditoriumDao().insert( db.auditoriumDao().insert(
auditoriums.map { it.toEntity() } auditoriums.map { it.toEntity() }
) )
}
} }
@Entity @Entity
@@ -105,7 +108,7 @@ data class DisciplineDB(
@Dao @Dao
interface DisciplineDao { interface DisciplineDao {
@Query("SELECT * FROM disciplinedb WHERE oid = :oid LIMIT 1") @Query("SELECT * FROM disciplinedb WHERE oid = :oid LIMIT 1")
fun get(oid: Int): DisciplineDB? suspend fun get(oid: Int): DisciplineDB?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(disciplines: List<DisciplineDB>) suspend fun insert(disciplines: List<DisciplineDB>)
@@ -130,7 +133,7 @@ data class KindOfWorkDB(
@Dao @Dao
interface KindOfWorkDao { interface KindOfWorkDao {
@Query("SELECT * FROM kindofworkdb WHERE oid = :oid LIMIT 1") @Query("SELECT * FROM kindofworkdb WHERE oid = :oid LIMIT 1")
fun get(oid: Int): KindOfWorkDB? suspend fun get(oid: Int): KindOfWorkDB?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(kindOfWork: List<KindOfWorkDB>) suspend fun insert(kindOfWork: List<KindOfWorkDB>)
@@ -162,7 +165,7 @@ data class LecturerDB(
@Dao @Dao
interface LecturerDao { interface LecturerDao {
@Query("SELECT * FROM lecturerdb WHERE unnId = :unnId LIMIT 1") @Query("SELECT * FROM lecturerdb WHERE unnId = :unnId LIMIT 1")
fun get(unnId: Int): LecturerDB? suspend fun get(unnId: Int): LecturerDB?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(lecturers: List<LecturerDB>) suspend fun insert(lecturers: List<LecturerDB>)
@@ -200,10 +203,10 @@ data class ScheduleUnitDB(
@Dao @Dao
interface ScheduleItemDao { interface ScheduleItemDao {
@Query("SELECT * FROM scheduleUnitDB WHERE oid = :oid LIMIT 1") @Query("SELECT * FROM scheduleUnitDB WHERE oid = :oid LIMIT 1")
fun getSchedule(oid: Int): ScheduleUnitDB? suspend fun getSchedule(oid: Int): ScheduleUnitDB?
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`") @Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
fun getSchedule(date: String): List<ScheduleUnitDB> suspend fun getSchedule(date: String): List<ScheduleUnitDB>
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`") @Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
fun getScheduleFlow(date: String): Flow<List<ScheduleUnitDB>> fun getScheduleFlow(date: String): Flow<List<ScheduleUnitDB>>
@@ -212,23 +215,30 @@ interface ScheduleItemDao {
suspend fun insert(units: List<ScheduleUnitDB>) suspend fun insert(units: List<ScheduleUnitDB>)
@Delete @Delete
fun delete(unit: ScheduleUnitDB) suspend fun delete(unit: ScheduleUnitDB)
@Query("DELETE FROM scheduleUnitDB WHERE date = :date") @Query("DELETE FROM scheduleUnitDB WHERE date = :date")
fun deleteByDate(date: String) suspend fun deleteByDate(date: String)
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
fun observe(date: String): Flow<List<ScheduleUnitDB>>
@Query("SELECT * FROM scheduleUnitDB")
suspend fun getAll(): List<ScheduleUnitDB>
} }
fun ScheduleUnitDB.toModel(): ScheduleUnit? { suspend fun ScheduleUnitDB.toModel(db: AppDatabase): ScheduleUnit? {
return ScheduleUnit( return ScheduleUnit(
oid, oid,
cacheDb.auditoriumDao().get(auditoriumOid)?.toModel() ?: return null, db.auditoriumDao().get(auditoriumOid)?.toModel(db) ?: return null,
LocalDate.parse(date, DateTimeFormatter.ISO_DATE), LocalDate.parse(date, DateTimeFormatter.ISO_DATE),
cacheDb.disciplineDao().get(disciplineOid)?.toModel() ?: return null, db.disciplineDao().get(disciplineOid)?.toModel() ?: return null,
cacheDb.kindOfWorkDao().get(kindOfWorkOid)?.toModel() ?: return null, db.kindOfWorkDao().get(kindOfWorkOid)?.toModel() ?: return null,
lecturersId.split(",").map { cacheDb.lecturerDao().get(it.toInt())?.toModel() ?: return null }, lecturersId.split(",").map { db.lecturerDao().get(it.toInt())?.toModel() ?: return null },
stream, stream,
LocalTime.parse(begin, DateTimeFormatter.ISO_TIME), LocalTime.parse(begin, DateTimeFormatter.ISO_TIME),
LocalTime.parse(end, DateTimeFormatter.ISO_TIME) LocalTime.parse(end, DateTimeFormatter.ISO_TIME),
LocalDateTime.parse(updatedAt, DateTimeFormatter.ISO_DATE_TIME),
) )
} }
fun ScheduleUnit.toEntity() = ScheduleUnitDB( fun ScheduleUnit.toEntity() = ScheduleUnitDB(
@@ -244,13 +254,16 @@ fun ScheduleUnit.toEntity() = ScheduleUnitDB(
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
) )
suspend fun cacheSchedule(units: List<ScheduleUnit>) { suspend fun cacheSchedule(db: AppDatabase, units: List<ScheduleUnit>) {
cacheAuditorium(units.map { it.auditorium }) db.withTransaction {
cacheDb.disciplineDao().insert(units.map { it.discipline.toEntity() }) cacheAuditorium(db, units.map { it.auditorium })
cacheDb.kindOfWorkDao().insert(units.map { it.kindOfWork.toEntity() }) db.disciplineDao().insert(units.map { it.discipline.toEntity() })
cacheDb.lecturerDao().insert(units.flatMap { it.lecturers }.map { it.toEntity() }) db.kindOfWorkDao().insert(units.map { it.kindOfWork.toEntity() })
cacheDb.scheduleDao().insert(units.map { it.toEntity() }) db.lecturerDao().insert(units.flatMap { it.lecturers }.map { it.toEntity() })
db.scheduleDao().insert(units.map { it.toEntity() })
}
} }
fun loadSchedule(date: LocalDate): List<ScheduleUnit> =
cacheDb.scheduleDao().getSchedule(date.format(DateTimeFormatter.ISO_DATE)) //fun loadSchedule(db: AppDatabase, date: LocalDate): List<ScheduleUnit> =
.mapNotNull { it.toModel() } // db.scheduleDao().getSchedule(date.format(DateTimeFormatter.ISO_DATE))
// .mapNotNull { it.toModel(db) }
@@ -1,8 +1,7 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). // Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.db package ru.sweetbread.unn.db
import android.util.Log
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
@@ -10,12 +9,7 @@ import androidx.room.Entity
import androidx.room.Insert import androidx.room.Insert
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Query import androidx.room.Query
import ru.sweetbread.unn.api.AvatarSet import ru.sweetbread.unn.model.Type
import ru.sweetbread.unn.api.Type
import ru.sweetbread.unn.api.User
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Entity @Entity
data class UserDB( data class UserDB(
@@ -46,54 +40,54 @@ interface UserDao {
} }
fun cacheUser(user: User) { //fun cacheUser(user: User) {
try { // try {
cacheDb.userDao().insert( // cacheDb.userDao().insert(
UserDB( // UserDB(
user.userId, // user.userId,
user.unnId, // user.unnId,
user.type, // user.type,
user.email, // user.email,
user.nameRu, // user.nameRu,
user.nameEn, // user.nameEn,
user.isMale, // user.isMale,
user.birthday.format(DateTimeFormatter.ISO_LOCAL_DATE), // user.birthday.format(DateTimeFormatter.ISO_LOCAL_DATE),
user.avatar.original, // user.avatar.original,
user.avatar.thumbnail, // user.avatar.thumbnail,
user.avatar.small, // user.avatar.small,
LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) // LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
) // )
) // )
} catch (_: android.database.sqlite.SQLiteConstraintException) { // } catch (_: android.database.sqlite.SQLiteConstraintException) {
} // }
} //}
//
fun loadUserByBitrixId(bitrixId: Int): User? { //fun loadUserByBitrixId(bitrixId: Int): User? {
val user = cacheDb.userDao().getUserById(bitrixId) // val user = cacheDb.userDao().getUserById(bitrixId)
Log.d("UserDB", user?.nameEn ?: "None") // Log.d("UserDB", user?.nameEn ?: "None")
if (user == null) return null // if (user == null) return null
if (LocalDateTime.parse( // if (LocalDateTime.parse(
user.expiredAt, // user.expiredAt,
DateTimeFormatter.ISO_LOCAL_DATE_TIME // DateTimeFormatter.ISO_LOCAL_DATE_TIME
) < LocalDateTime.now() // ) < LocalDateTime.now()
) { // ) {
cacheDb.userDao().delete(user) // cacheDb.userDao().delete(user)
return null // return null
} else { // } else {
return User( // return User(
user.unnId, // user.unnId,
user.userId, // user.userId,
user.type, // user.type,
user.email, // user.email,
user.nameRu, // user.nameRu,
user.nameEn, // user.nameEn,
user.isMale, // user.isMale,
LocalDate.parse(user.birthday, DateTimeFormatter.ISO_LOCAL_DATE), // LocalDate.parse(user.birthday, DateTimeFormatter.ISO_LOCAL_DATE),
AvatarSet( // AvatarSet(
user.origAvatar, // user.origAvatar,
user.thumbAvatar, // user.thumbAvatar,
user.smallAvatar // user.smallAvatar
) // )
) // )
} // }
} //}
@@ -1,6 +1,6 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). // Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes package ru.sweetbread.unn.feature.schedule
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@@ -41,22 +41,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import ru.sweetbread.unn.R import ru.sweetbread.unn.R
import ru.sweetbread.unn.api.Auditorium import ru.sweetbread.unn.api.Auditorium
import ru.sweetbread.unn.api.Building import ru.sweetbread.unn.api.Building
@@ -65,9 +51,8 @@ import ru.sweetbread.unn.api.KindOfWork
import ru.sweetbread.unn.api.Lecturer import ru.sweetbread.unn.api.Lecturer
import ru.sweetbread.unn.api.LecturerRank import ru.sweetbread.unn.api.LecturerRank
import ru.sweetbread.unn.api.ScheduleUnit import ru.sweetbread.unn.api.ScheduleUnit
import ru.sweetbread.unn.api.getScheduleDay import ru.sweetbread.unn.ui.composes.BoundedWeekPicker
import ru.sweetbread.unn.db.cacheDb import ru.sweetbread.unn.ui.composes.GroupsNameText
import ru.sweetbread.unn.db.toModel
import ru.sweetbread.unn.ui.theme.UNNTheme import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.resources.appStr import splitties.resources.appStr
import java.time.LocalDate import java.time.LocalDate
@@ -76,70 +61,22 @@ import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Calendar import java.util.Calendar
import kotlin.time.Duration.Companion.milliseconds
class ScheduleRepository {
fun getFromDb(date: LocalDate): List<ScheduleUnit> =
cacheDb.scheduleDao()
.getSchedule(date.format(DateTimeFormatter.ISO_DATE))
.mapNotNull { it.toModel() }
suspend fun getFromApi(date: LocalDate): List<ScheduleUnit> = getScheduleDay(date)
}
sealed class ScheduleState {
object Loading : ScheduleState()
data class Success(val data: List<ScheduleUnit>) : ScheduleState()
data class Error(val message: String) : ScheduleState()
}
class ScheduleViewModel : ViewModel() {
private val repository = ScheduleRepository()
private val _date = MutableStateFlow(LocalDate.now())
val date: StateFlow<LocalDate> = _date.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val scheduleState: StateFlow<ScheduleState> = _date
.flatMapLatest { date ->
flow {
emit(ScheduleState.Loading)
val cached = withContext(Dispatchers.IO) {
repository.getFromDb(date)
}
emit(ScheduleState.Success(cached))
try {
val fresh = repository.getFromApi(date)
if (fresh != cached) {
emit(ScheduleState.Success(fresh))
}
} catch (e: Exception) {
emit(ScheduleState.Error("Ошибка загрузки: ${e.message}"))
}
}.flowOn(Dispatchers.IO)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ScheduleState.Loading
)
fun updateDate(newDate: LocalDate) {
_date.update { newDate }
}
}
@Composable @Composable
fun Schedule() { fun Schedule() {
val viewModel: ScheduleViewModel = viewModel() val viewModel: ScheduleViewModel = viewModel(
factory = ScheduleViewModel.Factory
)
Column { Column {
BoundedWeekPicker( BoundedWeekPicker(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
selectedDate = viewModel.date.collectAsState().value, selectedDate = viewModel.uiState.collectAsState().value.date,
onDateSelected = { viewModel.updateDate(it) } onDateSelected = { viewModel.selectDate(it) }
) )
ScheduleDay(viewModel = viewModel) ScheduleDay(viewModel = viewModel)
@@ -149,33 +86,28 @@ fun Schedule() {
@Composable @Composable
fun ScheduleDay(modifier: Modifier = Modifier, viewModel: ScheduleViewModel) { fun ScheduleDay(modifier: Modifier = Modifier, viewModel: ScheduleViewModel) {
var expanded by remember { mutableIntStateOf(0) } var expanded by remember { mutableIntStateOf(0) }
val state by viewModel.scheduleState.collectAsState() val state by viewModel.uiState.collectAsState()
when (state) { val lessons = state.lessons
is ScheduleState.Success -> { LazyColumn(modifier) {
val lessons = (state as ScheduleState.Success).data items(
LazyColumn(modifier) { items = lessons,
items(lessons) { key = { it.oid }
ScheduleItem(unit = it, modifier = Modifier.clickable { ) {
expanded = if (it.oid == expanded) 0 ScheduleItem(unit = it, modifier = Modifier.clickable {
else it.oid expanded = if (it.oid == expanded) 0
}, expanded = expanded == it.oid) else it.oid
} }, expanded = expanded == it.oid)
if (lessons.isEmpty())
item { Text(appStr(R.string.noData)) }
}
} }
is ScheduleState.Loading ->
LinearProgressIndicator( if (state.isRefreshing) {
modifier = Modifier item { LinearProgressIndicator(Modifier.fillMaxWidth()) }
.fillMaxWidth() } else {
.padding(8.dp), if (lessons.isEmpty())
color = MaterialTheme.colorScheme.surfaceVariant, item { Text(appStr(R.string.noData)) }
trackColor = MaterialTheme.colorScheme.secondary, }
)
is ScheduleState.Error -> state.error?.let { item { Text(it) } }
Text((state as ScheduleState.Error).message, color = MaterialTheme.colorScheme.error)
} }
} }
@@ -194,16 +126,34 @@ fun ScheduleItem(modifier: Modifier = Modifier, unit: ScheduleUnit, expanded: Bo
val begin = unit.begin.format(DateTimeFormatter.ofPattern("HH:mm")) val begin = unit.begin.format(DateTimeFormatter.ofPattern("HH:mm"))
val end = unit.end.format(DateTimeFormatter.ofPattern("HH:mm")) val end = unit.end.format(DateTimeFormatter.ofPattern("HH:mm"))
var ratio by remember { mutableFloatStateOf(getRatio()) } var ratio by remember(
unit.date,
unit.begin,
unit.end
) {
mutableFloatStateOf(getRatio())
}
LaunchedEffect(
unit.date,
unit.begin,
unit.end
) {
ratio = getRatio()
LaunchedEffect(Unit) {
while (true) { while (true) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val calendar = Calendar.getInstance().apply { timeInMillis = now } val calendar = Calendar.getInstance().apply {
timeInMillis = now
}
val seconds = calendar.get(Calendar.SECOND) val seconds = calendar.get(Calendar.SECOND)
val millisUntilNextMinute = (60 - seconds) * 1000L - calendar.get(Calendar.MILLISECOND) val millisUntilNextMinute =
(60 - seconds) * 1000L -
calendar.get(Calendar.MILLISECOND)
delay(millisUntilNextMinute.milliseconds)
delay(millisUntilNextMinute)
ratio = getRatio() ratio = getRatio()
} }
} }
@@ -422,7 +372,8 @@ fun ScheduleItemPreview() {
), ),
stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP", stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP",
begin = LocalTime.of(10, 50), begin = LocalTime.of(10, 50),
end = LocalTime.of(12, 20) end = LocalTime.of(12, 20),
LocalDateTime.now()
) )
UNNTheme { UNNTheme {
@@ -470,7 +421,8 @@ fun ScheduleExpandedItemPreview() {
), ),
stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP", stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP",
begin = LocalTime.of(10, 50), begin = LocalTime.of(10, 50),
end = LocalTime.of(12, 20) end = LocalTime.of(12, 20),
LocalDateTime.now()
) )
UNNTheme { UNNTheme {
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.schedule
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.sweetbread.unn.api.ScheduleUnit
import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.core.network.PortalApi
import ru.sweetbread.unn.db.AppDatabase
import ru.sweetbread.unn.db.cacheSchedule
import ru.sweetbread.unn.db.toModel
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class ScheduleRepository (
val api: PortalApi,
val db: AppDatabase,
val session: SessionManager
) {
fun observeSchedule(date: LocalDate): Flow<List<ScheduleUnit>> =
db.scheduleDao()
.observe(date.format(DateTimeFormatter.ISO_DATE))
.map { ent -> ent.mapNotNull { it.toModel(db) } }
suspend fun refresh(date: LocalDate, diffHrs: Long = 24) {
val dateStr = date.format(DateTimeFormatter.ISO_DATE)
val fromDb = db.scheduleDao()
.getSchedule(dateStr)
.mapNotNull { it.toModel(db) }
if (
fromDb.isEmpty() ||
fromDb.minOf { it.lastUpdated } < LocalDateTime.now().minusHours(diffHrs)
) {
val fromApi = api.ruzapi.getScheduleByUns(from = date, until = date)
db.withTransaction {
db.scheduleDao().deleteByDate(dateStr)
cacheSchedule(db, fromApi)
}
}
}
}
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.schedule
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import ru.sweetbread.unn.UNNApp
import ru.sweetbread.unn.api.ScheduleUnit
import java.time.LocalDate
data class ScheduleUiState(
val date: LocalDate = LocalDate.now(),
val isRefreshing: Boolean = false,
val error: String? = null,
val lessons: List<ScheduleUnit> = emptyList(),
)
class ScheduleViewModel(
private val repository: ScheduleRepository
) : ViewModel() {
companion object {
val Factory = viewModelFactory {
initializer {
val application = checkNotNull(
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
) as UNNApp
ScheduleViewModel(
repository = application.container.scheduleRepository
)
}
}
}
private val date = MutableStateFlow(LocalDate.now())
private val refreshing = MutableStateFlow(false)
private val error = MutableStateFlow<String?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
private val lessons =
date.flatMapLatest(repository::observeSchedule)
val uiState =
combine(
date,
refreshing,
error,
lessons,
) { date, refreshing, error, lessons ->
ScheduleUiState(
date = date,
isRefreshing = refreshing,
error = error,
lessons = lessons
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
ScheduleUiState()
)
fun selectDate(date: LocalDate) {
this.date.value = date
refresh()
}
fun refresh() {
viewModelScope.launch {
refreshing.value = true
error.value = null
try {
repository.refresh(date.value)
} catch (e: Exception) {
error.value = e.message
} finally {
refreshing.value = false
}
}
}
}
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.model
import org.json.JSONObject
import ru.sweetbread.unn.R
import ru.sweetbread.unn.api.AvatarSet
import splitties.resources.appStr
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
enum class Type(val s: String) {
student(appStr(R.string.student)),
group("group"),
lecturer(appStr(R.string.lecturer)),
auditorium("auditorium"),
employee(appStr(R.string.employee))
}
class User(
// val unnId: Int?,
// val userId: Int,
val bx_id: Int?, // Bitrix user ID
val ra_id: Int?, // ruzapi ID (for schedule)
val type: Type,
val email: String,
val nameRu: String,
val nameEn: String,
val isMale: Boolean,
val birthday: LocalDate,
val avatar: AvatarSet
) {
constructor(json: JSONObject) : this (
json.getInt("bitrix_id"),
null,
when (json.getString("type")) {
"student" -> Type.student
else -> Type.employee
},
json.getJSONObject("user").getString("email"),
json.getJSONObject("user").getString("fullname"),
json.getJSONObject("user").getString("fullname_en"),
json.getJSONObject("user").getString("sex") == "M",
Instant
.parse(json.getJSONObject("user").getString("birthdate"))
.atZone(ZoneId.of("Europe/Moscow"))
.toLocalDate(),
json.getJSONObject("user").getJSONObject("photo").let {
AvatarSet(
it.getString("orig"),
it.getString("thumbnail"),
it.getString("small"),
)
}
)
}
@@ -1,342 +1,342 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). // Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes package ru.sweetbread.unn.ui.composes
import android.graphics.drawable.Drawable //import android.graphics.drawable.Drawable
import android.text.Html //import android.text.Html
import android.text.method.LinkMovementMethod //import android.text.method.LinkMovementMethod
import android.text.util.Linkify //import android.text.util.Linkify
import android.util.Log //import android.util.Log
import android.widget.TextView //import android.widget.TextView
import androidx.compose.foundation.background //import androidx.compose.foundation.background
import androidx.compose.foundation.clickable //import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement //import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column //import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row //import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth //import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding //import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size //import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width //import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn //import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items //import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape //import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider //import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator //import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme //import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface //import androidx.compose.material3.Surface
import androidx.compose.material3.Text //import androidx.compose.material3.Text
import androidx.compose.runtime.Composable //import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect //import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable //import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.collectAsState //import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue //import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf //import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf //import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember //import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue //import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment //import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier //import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip //import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb //import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource //import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle //import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight //import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview //import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp //import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView //import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.ViewModel //import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel //import androidx.lifecycle.viewmodel.compose.viewModel
import coil.ImageLoader //import coil.ImageLoader
import coil.compose.AsyncImage //import coil.compose.AsyncImage
import coil.request.ImageRequest //import coil.request.ImageRequest
import kotlinx.coroutines.flow.MutableStateFlow //import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow //import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow //import kotlinx.coroutines.flow.asStateFlow
import ru.sweetbread.unn.R //import ru.sweetbread.unn.R
import ru.sweetbread.unn.api.AvatarSet //import ru.sweetbread.unn.api.AvatarSet
import ru.sweetbread.unn.api.Post //import ru.sweetbread.unn.api.Post
import ru.sweetbread.unn.api.Type //import ru.sweetbread.unn.api.Type
import ru.sweetbread.unn.api.User //import ru.sweetbread.unn.api.User
import ru.sweetbread.unn.api.getBlogposts //import ru.sweetbread.unn.core.network.getBlogposts
import ru.sweetbread.unn.api.getUserByBitrixId //import ru.sweetbread.unn.core.network.getUserByBitrixId
import ru.sweetbread.unn.api.portalURL //import ru.sweetbread.unn.core.network.portalURL
import ru.sweetbread.unn.ui.theme.UNNTheme //import ru.sweetbread.unn.ui.theme.UNNTheme
import java.time.LocalDate //import java.time.LocalDate
import java.time.LocalDateTime //import java.time.LocalDateTime
import java.time.format.DateTimeFormatter //import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle //import java.time.format.FormatStyle
//
//
val defUser = User( //val defUser = User(
null, // null,
123, // 123,
Type.student, // Type.student,
"cool.email@domain.com", // "cool.email@domain.com",
"Джон Сигма Омегович", // "Джон Сигма Омегович",
"Jon Sigma Omega", // "Jon Sigma Omega",
true, // true,
LocalDate.now(), // LocalDate.now(),
AvatarSet( // AvatarSet(
"https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg", // "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
"https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg", // "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
"https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg" // "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg"
) // )
) //)
//
//
@Composable //@Composable
fun Blogposts(viewModel: PostViewModel = viewModel()) { //fun Blogposts(viewModel: PostViewModel = viewModel()) {
val posts by viewModel.posts.collectAsState() // val posts by viewModel.posts.collectAsState()
var extended by remember { mutableIntStateOf(0) } // var extended by remember { mutableIntStateOf(0) }
//
LaunchedEffect(Unit) { // LaunchedEffect(Unit) {
viewModel.loadPosts() // viewModel.loadPosts()
} // }
//
if (posts.isNotEmpty()) { // if (posts.isNotEmpty()) {
Log.d("Another fuck", posts.size.toString()) // Log.d("Another fuck", posts.size.toString())
LazyColumn { // LazyColumn {
items(posts) { // items(posts) {
PostItem( // PostItem(
Modifier // Modifier
.padding(8.dp) // .padding(8.dp)
.clip(RoundedCornerShape(16.dp)) // .clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.secondaryContainer) // .background(MaterialTheme.colorScheme.secondaryContainer)
.clickable { // .clickable {
extended = if (extended == it.id) 0 else it.id // extended = if (extended == it.id) 0 else it.id
}, // },
post = it, // post = it,
extended == it.id // extended == it.id
) // )
} // }
} // }
} else { // } else {
LinearProgressIndicator( // LinearProgressIndicator(
modifier = Modifier // modifier = Modifier
.fillMaxWidth() // .fillMaxWidth()
.padding(8.dp), // .padding(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant, // color = MaterialTheme.colorScheme.surfaceVariant,
trackColor = MaterialTheme.colorScheme.secondary, // trackColor = MaterialTheme.colorScheme.secondary,
) // )
} // }
} //}
//
//
class PostRepository { //class PostRepository {
suspend fun loadPosts(): List<Post> { // suspend fun loadPosts(): List<Post> {
return getBlogposts() // return getBlogposts()
} // }
} //}
//
class PostViewModel : ViewModel() { //class PostViewModel : ViewModel() {
private val repository = PostRepository() // private val repository = PostRepository()
private val _posts = MutableStateFlow<List<Post>>(emptyList()) // private val _posts = MutableStateFlow<List<Post>>(emptyList())
val posts: StateFlow<List<Post>> = _posts.asStateFlow() // val posts: StateFlow<List<Post>> = _posts.asStateFlow()
//
suspend fun loadPosts() { // suspend fun loadPosts() {
_posts.value = repository.loadPosts() // _posts.value = repository.loadPosts()
} // }
} //}
//
//
@Composable //@Composable
@NonRestartableComposable //@NonRestartableComposable
fun UserItem(modifier: Modifier = Modifier, user: User, info: String? = null) { //fun UserItem(modifier: Modifier = Modifier, user: User, info: String? = null) {
Row( // Row(
modifier.padding(16.dp), // modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically, // verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween // horizontalArrangement = Arrangement.SpaceBetween
) { // ) {
AsyncImage( // AsyncImage(
modifier = Modifier // modifier = Modifier
.padding(end = 8.dp) // .padding(end = 8.dp)
.size(48.dp) // .size(48.dp)
.clip(RoundedCornerShape(50)), // .clip(RoundedCornerShape(50)),
model = portalURL + user.avatar.thumbnail, // model = portalURL + user.avatar.thumbnail,
contentDescription = user.nameEn // contentDescription = user.nameEn
) // )
//
Column { // Column {
Text(user.nameRu, fontWeight = FontWeight.Bold) // Text(user.nameRu, fontWeight = FontWeight.Bold)
if (!info.isNullOrBlank()) // if (!info.isNullOrBlank())
Text( // Text(
text = info, // text = info,
fontStyle = FontStyle.Italic, // fontStyle = FontStyle.Italic,
fontSize = MaterialTheme.typography.labelLarge.fontSize // fontSize = MaterialTheme.typography.labelLarge.fontSize
) // )
} // }
} // }
} //}
//
@Composable //@Composable
@NonRestartableComposable //@NonRestartableComposable
fun PostItem(modifier: Modifier = Modifier, post: Post, extended: Boolean = false) { //fun PostItem(modifier: Modifier = Modifier, post: Post, extended: Boolean = false) {
var user: User? by remember { mutableStateOf(null) } // var user: User? by remember { mutableStateOf(null) }
var html: String by remember { mutableStateOf("") } // var html: String by remember { mutableStateOf("") }
//
//
LaunchedEffect(post) { // LaunchedEffect(post) {
html = post.content // html = post.content
user = getUserByBitrixId(post.authorId) // user = getUserByBitrixId(post.authorId)
} // }
//
Column(modifier.padding(16.dp)) { // Column(modifier.padding(16.dp)) {
if (user != null) // if (user != null)
UserItem(user = user!!) // UserItem(user = user!!)
else // else
LinearProgressIndicator( // LinearProgressIndicator(
modifier = Modifier // modifier = Modifier
.fillMaxWidth() // .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 38.dp), // .padding(horizontal = 8.dp, vertical = 38.dp),
color = MaterialTheme.colorScheme.surfaceVariant, // color = MaterialTheme.colorScheme.surfaceVariant,
trackColor = MaterialTheme.colorScheme.secondary, // trackColor = MaterialTheme.colorScheme.secondary,
) // )
//
val textColor = MaterialTheme.colorScheme.onBackground.toArgb() // val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
val linkColor = MaterialTheme.colorScheme.primary.toArgb() // val linkColor = MaterialTheme.colorScheme.primary.toArgb()
//
AndroidView( // AndroidView(
modifier = Modifier, // modifier = Modifier,
factory = { // factory = {
TextView(it).apply { // TextView(it).apply {
movementMethod = LinkMovementMethod.getInstance() // movementMethod = LinkMovementMethod.getInstance()
autoLinkMask = Linkify.WEB_URLS // autoLinkMask = Linkify.WEB_URLS
linksClickable = true // linksClickable = true
setTextColor(textColor) // setTextColor(textColor)
setLinkTextColor(linkColor) // setLinkTextColor(linkColor)
} // }
}, // },
update = { // update = {
it.maxLines = if (extended) Int.MAX_VALUE else 5 // it.maxLines = if (extended) Int.MAX_VALUE else 5
it.text = Html.fromHtml( // it.text = Html.fromHtml(
html, // html,
Html.FROM_HTML_MODE_LEGACY, // Html.FROM_HTML_MODE_LEGACY,
CoilImageGetter(it), // CoilImageGetter(it),
null // null
) // )
} // }
) // )
//
HorizontalDivider( // HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp), // modifier = Modifier.padding(vertical = 16.dp),
thickness = 1.dp, // thickness = 1.dp,
color = MaterialTheme.colorScheme.onBackground // color = MaterialTheme.colorScheme.onBackground
) // )
//
Text(text = post.date.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM))) // Text(text = post.date.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)))
} // }
} //}
//
//
@Preview //@Preview
@Composable //@Composable
fun UserItemPreview() { //fun UserItemPreview() {
UNNTheme { // UNNTheme {
Surface { // Surface {
UserItem( // UserItem(
Modifier // Modifier
.width(300.dp) // .width(300.dp)
.clip(RoundedCornerShape(8.dp)) // .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer), // .background(MaterialTheme.colorScheme.primaryContainer),
defUser, // defUser,
Type.student.s // Type.student.s
) // )
} // }
} // }
} //}
//
@Preview //@Preview
@Composable //@Composable
fun PostItemPreview() { //fun PostItemPreview() {
val post = Post( // val post = Post(
id = 154923, // id = 154923,
authorId = 165945, // authorId = 165945,
enableComments = true, // enableComments = true,
numComments = 0, // numComments = 0,
date = LocalDateTime.of(2024, 3, 20, 18, 55, 20), // date = LocalDateTime.of(2024, 3, 20, 18, 55, 20),
content = stringResource(id = R.string.lorem) // content = stringResource(id = R.string.lorem)
) // )
//
UNNTheme { // UNNTheme {
Surface { // Surface {
PostItem( // PostItem(
Modifier // Modifier
.clip(RoundedCornerShape(8.dp)) // .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer), post // .background(MaterialTheme.colorScheme.primaryContainer), post
) // )
} // }
} // }
} //}
//
//
class CoilImageGetter( //class CoilImageGetter(
private val textView: TextView, // private val textView: TextView,
private val maxImageWidth: Int = textView.width // private val maxImageWidth: Int = textView.width
) : Html.ImageGetter { //) : Html.ImageGetter {
//
override fun getDrawable(source: String): Drawable { // override fun getDrawable(source: String): Drawable {
val urlDrawable = UrlDrawable() // val urlDrawable = UrlDrawable()
//
if (maxImageWidth <= 0) // if (maxImageWidth <= 0)
textView.post { updateImage(source, urlDrawable, textView.width) } // textView.post { updateImage(source, urlDrawable, textView.width) }
else // else
updateImage(source, urlDrawable, maxImageWidth) // updateImage(source, urlDrawable, maxImageWidth)
//
return urlDrawable // return urlDrawable
} // }
//
private fun updateImage(source: String, urlDrawable: UrlDrawable, maxWidth: Int) { // private fun updateImage(source: String, urlDrawable: UrlDrawable, maxWidth: Int) {
val imageLoader = ImageLoader.Builder(textView.context) // val imageLoader = ImageLoader.Builder(textView.context)
.build() // .build()
//
val request = ImageRequest.Builder(textView.context) // val request = ImageRequest.Builder(textView.context)
.data(source) // .data(source)
.target { drawable -> // .target { drawable ->
val (scaledWidth, scaledHeight) = calculateScaledSize( // val (scaledWidth, scaledHeight) = calculateScaledSize(
drawable.intrinsicWidth, // drawable.intrinsicWidth,
drawable.intrinsicHeight, // drawable.intrinsicHeight,
maxWidth // maxWidth
) // )
//
drawable.setBounds(0, 0, scaledWidth, scaledHeight) // drawable.setBounds(0, 0, scaledWidth, scaledHeight)
urlDrawable.drawable = drawable // urlDrawable.drawable = drawable
urlDrawable.setBounds(0, 0, scaledWidth, scaledHeight) // urlDrawable.setBounds(0, 0, scaledWidth, scaledHeight)
//
textView.text = textView.text // textView.text = textView.text
} // }
.build() // .build()
//
imageLoader.enqueue(request) // imageLoader.enqueue(request)
} // }
//
private fun calculateScaledSize( // private fun calculateScaledSize(
originalWidth: Int, // originalWidth: Int,
originalHeight: Int, // originalHeight: Int,
maxWidth: Int // maxWidth: Int
): Pair<Int, Int> { // ): Pair<Int, Int> {
if (originalWidth <= maxWidth) // if (originalWidth <= maxWidth)
return Pair(originalWidth, originalHeight) // return Pair(originalWidth, originalHeight)
//
val ratio = maxWidth.toFloat() / originalWidth.toFloat() // val ratio = maxWidth.toFloat() / originalWidth.toFloat()
return Pair( // return Pair(
maxWidth, // maxWidth,
(originalHeight * ratio).toInt() // (originalHeight * ratio).toInt()
) // )
} // }
} //}
//
class UrlDrawable() : Drawable() { //class UrlDrawable() : Drawable() {
var drawable: Drawable? = null // var drawable: Drawable? = null
set(value) { // set(value) {
field = value // field = value
invalidateSelf() // invalidateSelf()
} // }
//
override fun draw(canvas: android.graphics.Canvas) { // override fun draw(canvas: android.graphics.Canvas) {
drawable?.draw(canvas) // drawable?.draw(canvas)
} // }
//
override fun setAlpha(alpha: Int) {} // override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {} // override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT // override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
} //}
@@ -1,4 +1,4 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). // Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.layout package ru.sweetbread.unn.ui.layout
@@ -41,9 +41,10 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import ru.sweetbread.unn.R import ru.sweetbread.unn.R
import ru.sweetbread.unn.api.auth import ru.sweetbread.unn.UNNApp
import ru.sweetbread.unn.ui.theme.UNNTheme import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.activities.start import splitties.activities.start
import splitties.init.appCtx
import splitties.preferences.Preferences import splitties.preferences.Preferences
object LoginData : Preferences("loginData") { object LoginData : Preferences("loginData") {
@@ -55,9 +56,11 @@ class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val api = (application as UNNApp).container.portalApi
if ((LoginData.login != "") and (LoginData.password != "")) if ((LoginData.login != "") and (LoginData.password != ""))
runBlocking { runBlocking {
if (auth()) { if (api.auth()) {
start<MainActivity>() start<MainActivity>()
finish() finish()
} }
@@ -110,6 +113,7 @@ fun LoginPanel(
ok: (login: String, password: String) -> Unit, ok: (login: String, password: String) -> Unit,
error: () -> Unit error: () -> Unit
) { ) {
val api = (appCtx as UNNApp).container.portalApi
var login by rememberSaveable { mutableStateOf("") } var login by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") }
var loading by remember { mutableStateOf(false) } var loading by remember { mutableStateOf(false) }
@@ -148,7 +152,7 @@ fun LoginPanel(
onClick = { onClick = {
loading = true loading = true
scope.launch { scope.launch {
if (auth(login, password)) if (api.auth(login, password))
ok(login, password) ok(login, password)
else else
error() error()
@@ -41,6 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -59,11 +60,10 @@ import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.sweetbread.unn.R import ru.sweetbread.unn.R
import ru.sweetbread.unn.api.ME import ru.sweetbread.unn.UNNApp
import ru.sweetbread.unn.api.portalURL import ru.sweetbread.unn.core.network.portalURL
import ru.sweetbread.unn.feature.schedule.Schedule
import ru.sweetbread.unn.ui.composes.About import ru.sweetbread.unn.ui.composes.About
import ru.sweetbread.unn.ui.composes.Blogposts
import ru.sweetbread.unn.ui.composes.Schedule
import ru.sweetbread.unn.ui.theme.UNNTheme import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.resources.appStr import splitties.resources.appStr
import splitties.toast.toast import splitties.toast.toast
@@ -175,7 +175,8 @@ class MainActivity : ComponentActivity() {
Box(Modifier.padding(innerPadding)) { Box(Modifier.padding(innerPadding)) {
NavHost(navController, startDestination = "journal/schedule") { NavHost(navController, startDestination = "journal/schedule") {
composable("portal/blogposts") { composable("portal/blogposts") {
Blogposts() Text("Not implemented")
// Blogposts()
} }
composable("journal/schedule") { composable("journal/schedule") {
Schedule() Schedule()
@@ -194,87 +195,96 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun DrawerContent(modifier: Modifier = Modifier, navController: NavController) { fun DrawerContent(modifier: Modifier = Modifier, navController: NavController) {
Column(modifier = modifier.padding(0.dp)) { val me = (application as UNNApp).container.sessionManager.user.collectAsState().value
Row(
Modifier
.padding(8.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(16.dp))
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
modifier = Modifier.size(64.dp).clip(CircleShape),
model = portalURL + ME.avatar.thumbnail,
contentDescription = ME.nameEn
)
Column (Modifier.padding(horizontal = 8.dp)) { if (me != null) {
Text( Column(modifier = modifier.padding(0.dp)) {
ME.nameRu.split(" ").dropLast(1).joinToString(" "), Row(
fontWeight = FontWeight.Bold Modifier
.padding(8.dp)
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.primaryContainer,
RoundedCornerShape(16.dp)
)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
modifier = Modifier.size(64.dp).clip(CircleShape),
model = portalURL + me.avatar.thumbnail,
contentDescription = me.nameEn
) )
Text(ME.type.s)
Column(Modifier.padding(horizontal = 8.dp)) {
Text(
me.nameRu.split(" ").dropLast(1).joinToString(" "),
fontWeight = FontWeight.Bold
)
Text(me.type.s)
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
TextButton(
{},
Modifier.fillMaxWidth(),
shape = RectangleShape,
enabled = false
) {
Text(
appStr(R.string.record_book),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
}
TextButton(
{},
Modifier.fillMaxWidth(),
shape = RectangleShape,
enabled = false
) {
Text(
appStr(R.string.documents),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
}
TextButton(
{},
Modifier.fillMaxWidth(),
shape = RectangleShape,
enabled = false
) {
Text(
appStr(R.string.materials),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
}
Spacer(Modifier.weight(1f))
HorizontalDivider()
TextButton(
{ navController.navigate("about") },
Modifier.fillMaxWidth(),
shape = RectangleShape
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "Info",
modifier = Modifier.padding(horizontal = 4.dp)
)
Text(appStr(R.string.about_app))
} }
} }
} else {
Spacer(modifier = Modifier.height(16.dp)) Text("User not found")
HorizontalDivider()
TextButton(
{},
Modifier.fillMaxWidth(),
shape = RectangleShape,
enabled = false
) {
Text(
appStr(R.string.record_book),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
}
TextButton(
{},
Modifier.fillMaxWidth(),
shape = RectangleShape,
enabled = false
) {
Text(
appStr(R.string.documents),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
}
TextButton(
{},
Modifier.fillMaxWidth(),
shape = RectangleShape,
enabled = false
) {
Text(
appStr(R.string.materials),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
}
Spacer(Modifier.weight(1f))
HorizontalDivider()
TextButton(
{navController.navigate("about")},
Modifier.fillMaxWidth(),
shape = RectangleShape
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "Info",
modifier = Modifier.padding(horizontal = 4.dp)
)
Text(appStr(R.string.about_app))
}
} }
} }
} }