diff --git a/app/src/main/java/ru/sweetbread/unn/UNNApp.kt b/app/src/main/java/ru/sweetbread/unn/UNNApp.kt index 8cd5ff8..a0eec65 100644 --- a/app/src/main/java/ru/sweetbread/unn/UNNApp.kt +++ b/app/src/main/java/ru/sweetbread/unn/UNNApp.kt @@ -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 import android.app.Application 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() { + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + + container = AppContainer(this) + } + override fun attachBaseContext(base: Context) { super.attachBaseContext(base) } +} + +class AppContainer(context: Context) { + val sessionManager = SessionManager() + + val httpClient = createHttpClient() + + val portalApi = PortalApi( + client = httpClient, + session = sessionManager + ) + + val database = roomDb(name = "cache") { + fallbackToDestructiveMigration(dropAllTables = true) + } + + val scheduleRepository = ScheduleRepository( + api = portalApi, + db = database, + session = sessionManager + ) } \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/api/API.kt b/app/src/main/java/ru/sweetbread/unn/api/API.kt deleted file mode 100644 index 90009c1..0000000 --- a/app/src/main/java/ru/sweetbread/unn/api/API.kt +++ /dev/null @@ -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 { - 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 { - val schedule = downloadSchedule(type, id, start, finish) - - if ((type == ME.type) and (id == ME.unnId!!)) - cacheSchedule(schedule) - - return schedule -} diff --git a/app/src/main/java/ru/sweetbread/unn/api/endpoints.kt b/app/src/main/java/ru/sweetbread/unn/api/endpoints.kt deleted file mode 100644 index 63a834b..0000000 --- a/app/src/main/java/ru/sweetbread/unn/api/endpoints.kt +++ /dev/null @@ -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 { - 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() - for (i in 0 until json.length()) { - val unit = json.getJSONObject(i) - val lecturesJson = unit.getJSONArray("listOfLecturers") - val lecturers = arrayListOf() - - 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 { - 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() - 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 -} diff --git a/app/src/main/java/ru/sweetbread/unn/api/models.kt b/app/src/main/java/ru/sweetbread/unn/api/models.kt index 491a4df..4021086 100644 --- a/app/src/main/java/ru/sweetbread/unn/api/models.kt +++ b/app/src/main/java/ru/sweetbread/unn/api/models.kt @@ -3,19 +3,10 @@ package ru.sweetbread.unn.api import ru.sweetbread.unn.R -import splitties.resources.appStr import java.time.LocalDate import java.time.LocalDateTime 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) { Assistant(R.string.assistant), Lecturer(R.string.lecturer), @@ -32,7 +23,8 @@ class ScheduleUnit( val lecturers: List, val stream: String, val begin: LocalTime, - val end: LocalTime + val end: LocalTime, + val lastUpdated: LocalDateTime, ) class Auditorium( @@ -69,18 +61,6 @@ class Lecturer( 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( val id: Int, val authorId: Int, diff --git a/app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt b/app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt new file mode 100644 index 0000000..8aaee26 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt @@ -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(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 + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt b/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt new file mode 100644 index 0000000..75344da --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt @@ -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 { +// 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 { +// val schedule = downloadSchedule(type, id, start, finish) +// +// if ((type == ME.type) and (id == ME.unnId!!)) +// cacheSchedule(schedule) +// +// return schedule +// } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/network/client.kt b/app/src/main/java/ru/sweetbread/unn/core/network/client.kt new file mode 100644 index 0000000..7c7cebb --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/network/client.kt @@ -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()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt b/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt new file mode 100644 index 0000000..d7e27ce --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt @@ -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 { + // 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 { + val json = JSONArray(body) + val out = arrayListOf() + for (i in 0 until json.length()) { + val unit = json.getJSONObject(i) + val lecturesJson = unit.getJSONArray("listOfLecturers") + val lecturers = arrayListOf() + + 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 { +// 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() +// for (i in 0 until json.length()) { +// val unit = json.getJSONObject(i) +// val lecturesJson = unit.getJSONArray("listOfLecturers") +// val lecturers = arrayListOf() +// +// 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 { +// 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() +// 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 +//} diff --git a/app/src/main/java/ru/sweetbread/unn/db/DB.kt b/app/src/main/java/ru/sweetbread/unn/db/DB.kt index b37583f..9f7d2fb 100644 --- a/app/src/main/java/ru/sweetbread/unn/db/DB.kt +++ b/app/src/main/java/ru/sweetbread/unn/db/DB.kt @@ -4,11 +4,6 @@ package ru.sweetbread.unn.db import androidx.room.Database import androidx.room.RoomDatabase -import splitties.arch.room.roomDb - -val cacheDb = roomDb(name = "cache") { - fallbackToDestructiveMigration(dropAllTables = true) -} @Database(entities = [ UserDB::class, diff --git a/app/src/main/java/ru/sweetbread/unn/db/Schedule.kt b/app/src/main/java/ru/sweetbread/unn/db/Schedule.kt index 6ebb49a..8085678 100644 --- a/app/src/main/java/ru/sweetbread/unn/db/Schedule.kt +++ b/app/src/main/java/ru/sweetbread/unn/db/Schedule.kt @@ -10,6 +10,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query +import androidx.room.withTransaction import kotlinx.coroutines.flow.Flow import ru.sweetbread.unn.api.Auditorium import ru.sweetbread.unn.api.Building @@ -34,7 +35,7 @@ data class BuildingDB( @Dao interface BuildingDao { @Query("SELECT * FROM buildingdb WHERE oid = :oid LIMIT 1") - fun get(oid: Int): BuildingDB? + suspend fun get(oid: Int): BuildingDB? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(buildings: List) @@ -59,7 +60,7 @@ data class AuditoriumDB( @Dao interface AuditoriumDao { @Query("SELECT * FROM auditoriumdb WHERE oid = :oid LIMIT 1") - fun get(oid: Int): AuditoriumDB? + suspend fun get(oid: Int): AuditoriumDB? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(auditoriums: List) @@ -68,12 +69,12 @@ interface AuditoriumDao { fun delete(auditorium: AuditoriumDB) } -fun AuditoriumDB.toModel(): Auditorium? { +suspend fun AuditoriumDB.toModel(db: AppDatabase): Auditorium? { return Auditorium( name, oid, floor ?: 0, - cacheDb.buildingDao().get(buildingOid)?.toModel() ?: return null + db.buildingDao().get(buildingOid)?.toModel() ?: return null ) } fun Auditorium.toEntity() = AuditoriumDB( @@ -84,14 +85,16 @@ fun Auditorium.toEntity() = AuditoriumDB( LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) ) -suspend fun cacheAuditorium(auditoriums: List) { - cacheDb.buildingDao().insert( - auditoriums.map { it.building }.map { it.toEntity() } - ) +suspend fun cacheAuditorium(db: AppDatabase, auditoriums: List) { + db.withTransaction { + db.buildingDao().insert( + auditoriums.map { it.building }.map { it.toEntity() } + ) - cacheDb.auditoriumDao().insert( - auditoriums.map { it.toEntity() } - ) + db.auditoriumDao().insert( + auditoriums.map { it.toEntity() } + ) + } } @Entity @@ -105,7 +108,7 @@ data class DisciplineDB( @Dao interface DisciplineDao { @Query("SELECT * FROM disciplinedb WHERE oid = :oid LIMIT 1") - fun get(oid: Int): DisciplineDB? + suspend fun get(oid: Int): DisciplineDB? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(disciplines: List) @@ -130,7 +133,7 @@ data class KindOfWorkDB( @Dao interface KindOfWorkDao { @Query("SELECT * FROM kindofworkdb WHERE oid = :oid LIMIT 1") - fun get(oid: Int): KindOfWorkDB? + suspend fun get(oid: Int): KindOfWorkDB? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(kindOfWork: List) @@ -162,7 +165,7 @@ data class LecturerDB( @Dao interface LecturerDao { @Query("SELECT * FROM lecturerdb WHERE unnId = :unnId LIMIT 1") - fun get(unnId: Int): LecturerDB? + suspend fun get(unnId: Int): LecturerDB? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(lecturers: List) @@ -200,10 +203,10 @@ data class ScheduleUnitDB( @Dao interface ScheduleItemDao { @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`") - fun getSchedule(date: String): List + suspend fun getSchedule(date: String): List @Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`") fun getScheduleFlow(date: String): Flow> @@ -212,23 +215,30 @@ interface ScheduleItemDao { suspend fun insert(units: List) @Delete - fun delete(unit: ScheduleUnitDB) + suspend fun delete(unit: ScheduleUnitDB) @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> + + @Query("SELECT * FROM scheduleUnitDB") + suspend fun getAll(): List } -fun ScheduleUnitDB.toModel(): ScheduleUnit? { +suspend fun ScheduleUnitDB.toModel(db: AppDatabase): ScheduleUnit? { return ScheduleUnit( oid, - cacheDb.auditoriumDao().get(auditoriumOid)?.toModel() ?: return null, + db.auditoriumDao().get(auditoriumOid)?.toModel(db) ?: return null, LocalDate.parse(date, DateTimeFormatter.ISO_DATE), - cacheDb.disciplineDao().get(disciplineOid)?.toModel() ?: return null, - cacheDb.kindOfWorkDao().get(kindOfWorkOid)?.toModel() ?: return null, - lecturersId.split(",").map { cacheDb.lecturerDao().get(it.toInt())?.toModel() ?: return null }, + db.disciplineDao().get(disciplineOid)?.toModel() ?: return null, + db.kindOfWorkDao().get(kindOfWorkOid)?.toModel() ?: return null, + lecturersId.split(",").map { db.lecturerDao().get(it.toInt())?.toModel() ?: return null }, stream, 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( @@ -244,13 +254,16 @@ fun ScheduleUnit.toEntity() = ScheduleUnitDB( LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) ) -suspend fun cacheSchedule(units: List) { - cacheAuditorium(units.map { it.auditorium }) - cacheDb.disciplineDao().insert(units.map { it.discipline.toEntity() }) - cacheDb.kindOfWorkDao().insert(units.map { it.kindOfWork.toEntity() }) - cacheDb.lecturerDao().insert(units.flatMap { it.lecturers }.map { it.toEntity() }) - cacheDb.scheduleDao().insert(units.map { it.toEntity() }) +suspend fun cacheSchedule(db: AppDatabase, units: List) { + db.withTransaction { + cacheAuditorium(db, units.map { it.auditorium }) + db.disciplineDao().insert(units.map { it.discipline.toEntity() }) + db.kindOfWorkDao().insert(units.map { it.kindOfWork.toEntity() }) + db.lecturerDao().insert(units.flatMap { it.lecturers }.map { it.toEntity() }) + db.scheduleDao().insert(units.map { it.toEntity() }) + } } -fun loadSchedule(date: LocalDate): List = - cacheDb.scheduleDao().getSchedule(date.format(DateTimeFormatter.ISO_DATE)) - .mapNotNull { it.toModel() } + +//fun loadSchedule(db: AppDatabase, date: LocalDate): List = +// db.scheduleDao().getSchedule(date.format(DateTimeFormatter.ISO_DATE)) +// .mapNotNull { it.toModel(db) } diff --git a/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt b/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt index c6f0a81..9a1d807 100644 --- a/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt +++ b/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt @@ -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 -import android.util.Log import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Delete @@ -10,12 +9,7 @@ import androidx.room.Entity import androidx.room.Insert import androidx.room.PrimaryKey import androidx.room.Query -import ru.sweetbread.unn.api.AvatarSet -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 +import ru.sweetbread.unn.model.Type @Entity data class UserDB( @@ -46,54 +40,54 @@ interface UserDao { } -fun cacheUser(user: User) { - try { - cacheDb.userDao().insert( - UserDB( - user.userId, - user.unnId, - user.type, - user.email, - user.nameRu, - user.nameEn, - user.isMale, - user.birthday.format(DateTimeFormatter.ISO_LOCAL_DATE), - user.avatar.original, - user.avatar.thumbnail, - user.avatar.small, - LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) - ) - ) - } catch (_: android.database.sqlite.SQLiteConstraintException) { - } -} - -fun loadUserByBitrixId(bitrixId: Int): User? { - val user = cacheDb.userDao().getUserById(bitrixId) - Log.d("UserDB", user?.nameEn ?: "None") - if (user == null) return null - if (LocalDateTime.parse( - user.expiredAt, - DateTimeFormatter.ISO_LOCAL_DATE_TIME - ) < LocalDateTime.now() - ) { - cacheDb.userDao().delete(user) - return null - } else { - return User( - user.unnId, - user.userId, - user.type, - user.email, - user.nameRu, - user.nameEn, - user.isMale, - LocalDate.parse(user.birthday, DateTimeFormatter.ISO_LOCAL_DATE), - AvatarSet( - user.origAvatar, - user.thumbAvatar, - user.smallAvatar - ) - ) - } -} \ No newline at end of file +//fun cacheUser(user: User) { +// try { +// cacheDb.userDao().insert( +// UserDB( +// user.userId, +// user.unnId, +// user.type, +// user.email, +// user.nameRu, +// user.nameEn, +// user.isMale, +// user.birthday.format(DateTimeFormatter.ISO_LOCAL_DATE), +// user.avatar.original, +// user.avatar.thumbnail, +// user.avatar.small, +// LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) +// ) +// ) +// } catch (_: android.database.sqlite.SQLiteConstraintException) { +// } +//} +// +//fun loadUserByBitrixId(bitrixId: Int): User? { +// val user = cacheDb.userDao().getUserById(bitrixId) +// Log.d("UserDB", user?.nameEn ?: "None") +// if (user == null) return null +// if (LocalDateTime.parse( +// user.expiredAt, +// DateTimeFormatter.ISO_LOCAL_DATE_TIME +// ) < LocalDateTime.now() +// ) { +// cacheDb.userDao().delete(user) +// return null +// } else { +// return User( +// user.unnId, +// user.userId, +// user.type, +// user.email, +// user.nameRu, +// user.nameEn, +// user.isMale, +// LocalDate.parse(user.birthday, DateTimeFormatter.ISO_LOCAL_DATE), +// AvatarSet( +// user.origAvatar, +// user.thumbAvatar, +// user.smallAvatar +// ) +// ) +// } +//} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/ui/composes/Schedule.kt b/app/src/main/java/ru/sweetbread/unn/feature/schedule/Schedule.kt similarity index 75% rename from app/src/main/java/ru/sweetbread/unn/ui/composes/Schedule.kt rename to app/src/main/java/ru/sweetbread/unn/feature/schedule/Schedule.kt index fe314d2..dd2b0fe 100644 --- a/app/src/main/java/ru/sweetbread/unn/ui/composes/Schedule.kt +++ b/app/src/main/java/ru/sweetbread/unn/feature/schedule/Schedule.kt @@ -1,6 +1,6 @@ // 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.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.zIndex -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi 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.api.Auditorium 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.LecturerRank import ru.sweetbread.unn.api.ScheduleUnit -import ru.sweetbread.unn.api.getScheduleDay -import ru.sweetbread.unn.db.cacheDb -import ru.sweetbread.unn.db.toModel +import ru.sweetbread.unn.ui.composes.BoundedWeekPicker +import ru.sweetbread.unn.ui.composes.GroupsNameText import ru.sweetbread.unn.ui.theme.UNNTheme import splitties.resources.appStr import java.time.LocalDate @@ -76,70 +61,22 @@ import java.time.LocalTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Calendar +import kotlin.time.Duration.Companion.milliseconds -class ScheduleRepository { - fun getFromDb(date: LocalDate): List = - cacheDb.scheduleDao() - .getSchedule(date.format(DateTimeFormatter.ISO_DATE)) - .mapNotNull { it.toModel() } - suspend fun getFromApi(date: LocalDate): List = getScheduleDay(date) -} - -sealed class ScheduleState { - object Loading : ScheduleState() - data class Success(val data: List) : ScheduleState() - data class Error(val message: String) : ScheduleState() -} - -class ScheduleViewModel : ViewModel() { - private val repository = ScheduleRepository() - - private val _date = MutableStateFlow(LocalDate.now()) - val date: StateFlow = _date.asStateFlow() - - @OptIn(ExperimentalCoroutinesApi::class) - val scheduleState: StateFlow = _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 fun Schedule() { - val viewModel: ScheduleViewModel = viewModel() + val viewModel: ScheduleViewModel = viewModel( + factory = ScheduleViewModel.Factory + ) Column { BoundedWeekPicker( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), - selectedDate = viewModel.date.collectAsState().value, - onDateSelected = { viewModel.updateDate(it) } + selectedDate = viewModel.uiState.collectAsState().value.date, + onDateSelected = { viewModel.selectDate(it) } ) ScheduleDay(viewModel = viewModel) @@ -149,33 +86,28 @@ fun Schedule() { @Composable fun ScheduleDay(modifier: Modifier = Modifier, viewModel: ScheduleViewModel) { var expanded by remember { mutableIntStateOf(0) } - val state by viewModel.scheduleState.collectAsState() + val state by viewModel.uiState.collectAsState() - when (state) { - is ScheduleState.Success -> { - val lessons = (state as ScheduleState.Success).data - LazyColumn(modifier) { - items(lessons) { - ScheduleItem(unit = it, modifier = Modifier.clickable { - expanded = if (it.oid == expanded) 0 - else it.oid - }, expanded = expanded == it.oid) - } - - if (lessons.isEmpty()) - item { Text(appStr(R.string.noData)) } - } + val lessons = state.lessons + LazyColumn(modifier) { + items( + items = lessons, + key = { it.oid } + ) { + ScheduleItem(unit = it, modifier = Modifier.clickable { + expanded = if (it.oid == expanded) 0 + else it.oid + }, expanded = expanded == it.oid) } - is ScheduleState.Loading -> - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - trackColor = MaterialTheme.colorScheme.secondary, - ) - is ScheduleState.Error -> - Text((state as ScheduleState.Error).message, color = MaterialTheme.colorScheme.error) + + if (state.isRefreshing) { + item { LinearProgressIndicator(Modifier.fillMaxWidth()) } + } else { + if (lessons.isEmpty()) + item { Text(appStr(R.string.noData)) } + } + + state.error?.let { item { Text(it) } } } } @@ -194,16 +126,34 @@ fun ScheduleItem(modifier: Modifier = Modifier, unit: ScheduleUnit, expanded: Bo val begin = unit.begin.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) { 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 millisUntilNextMinute = (60 - seconds) * 1000L - calendar.get(Calendar.MILLISECOND) + val millisUntilNextMinute = + (60 - seconds) * 1000L - + calendar.get(Calendar.MILLISECOND) + + delay(millisUntilNextMinute.milliseconds) - delay(millisUntilNextMinute) 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", begin = LocalTime.of(10, 50), - end = LocalTime.of(12, 20) + end = LocalTime.of(12, 20), + LocalDateTime.now() ) UNNTheme { @@ -470,7 +421,8 @@ fun ScheduleExpandedItemPreview() { ), stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP", begin = LocalTime.of(10, 50), - end = LocalTime.of(12, 20) + end = LocalTime.of(12, 20), + LocalDateTime.now() ) UNNTheme { diff --git a/app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleRepository.kt b/app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleRepository.kt new file mode 100644 index 0000000..dc951c3 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleRepository.kt @@ -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> = + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleViewModel.kt b/app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleViewModel.kt new file mode 100644 index 0000000..dd14eda --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleViewModel.kt @@ -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 = 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(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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/model/User.kt b/app/src/main/java/ru/sweetbread/unn/model/User.kt new file mode 100644 index 0000000..5d9af46 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/model/User.kt @@ -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"), + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt b/app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt index 26cc3d2..9139d04 100644 --- a/app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt +++ b/app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt @@ -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 -import android.graphics.drawable.Drawable -import android.text.Html -import android.text.method.LinkMovementMethod -import android.text.util.Linkify -import android.util.Log -import android.widget.TextView -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.draw.clip -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import coil.ImageLoader -import coil.compose.AsyncImage -import coil.request.ImageRequest -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import ru.sweetbread.unn.R -import ru.sweetbread.unn.api.AvatarSet -import ru.sweetbread.unn.api.Post -import ru.sweetbread.unn.api.Type -import ru.sweetbread.unn.api.User -import ru.sweetbread.unn.api.getBlogposts -import ru.sweetbread.unn.api.getUserByBitrixId -import ru.sweetbread.unn.api.portalURL -import ru.sweetbread.unn.ui.theme.UNNTheme -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle - - -val defUser = User( - null, - 123, - Type.student, - "cool.email@domain.com", - "Джон Сигма Омегович", - "Jon Sigma Omega", - true, - LocalDate.now(), - 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" - ) -) - - -@Composable -fun Blogposts(viewModel: PostViewModel = viewModel()) { - val posts by viewModel.posts.collectAsState() - var extended by remember { mutableIntStateOf(0) } - - LaunchedEffect(Unit) { - viewModel.loadPosts() - } - - if (posts.isNotEmpty()) { - Log.d("Another fuck", posts.size.toString()) - LazyColumn { - items(posts) { - PostItem( - Modifier - .padding(8.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable { - extended = if (extended == it.id) 0 else it.id - }, - post = it, - extended == it.id - ) - } - } - } else { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - trackColor = MaterialTheme.colorScheme.secondary, - ) - } -} - - -class PostRepository { - suspend fun loadPosts(): List { - return getBlogposts() - } -} - -class PostViewModel : ViewModel() { - private val repository = PostRepository() - private val _posts = MutableStateFlow>(emptyList()) - val posts: StateFlow> = _posts.asStateFlow() - - suspend fun loadPosts() { - _posts.value = repository.loadPosts() - } -} - - -@Composable -@NonRestartableComposable -fun UserItem(modifier: Modifier = Modifier, user: User, info: String? = null) { - Row( - modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - AsyncImage( - modifier = Modifier - .padding(end = 8.dp) - .size(48.dp) - .clip(RoundedCornerShape(50)), - model = portalURL + user.avatar.thumbnail, - contentDescription = user.nameEn - ) - - Column { - Text(user.nameRu, fontWeight = FontWeight.Bold) - if (!info.isNullOrBlank()) - Text( - text = info, - fontStyle = FontStyle.Italic, - fontSize = MaterialTheme.typography.labelLarge.fontSize - ) - } - } -} - -@Composable -@NonRestartableComposable -fun PostItem(modifier: Modifier = Modifier, post: Post, extended: Boolean = false) { - var user: User? by remember { mutableStateOf(null) } - var html: String by remember { mutableStateOf("") } - - - LaunchedEffect(post) { - html = post.content - user = getUserByBitrixId(post.authorId) - } - - Column(modifier.padding(16.dp)) { - if (user != null) - UserItem(user = user!!) - else - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 38.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - trackColor = MaterialTheme.colorScheme.secondary, - ) - - val textColor = MaterialTheme.colorScheme.onBackground.toArgb() - val linkColor = MaterialTheme.colorScheme.primary.toArgb() - - AndroidView( - modifier = Modifier, - factory = { - TextView(it).apply { - movementMethod = LinkMovementMethod.getInstance() - autoLinkMask = Linkify.WEB_URLS - linksClickable = true - setTextColor(textColor) - setLinkTextColor(linkColor) - } - }, - update = { - it.maxLines = if (extended) Int.MAX_VALUE else 5 - it.text = Html.fromHtml( - html, - Html.FROM_HTML_MODE_LEGACY, - CoilImageGetter(it), - null - ) - } - ) - - HorizontalDivider( - modifier = Modifier.padding(vertical = 16.dp), - thickness = 1.dp, - color = MaterialTheme.colorScheme.onBackground - ) - - Text(text = post.date.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM))) - } -} - - -@Preview -@Composable -fun UserItemPreview() { - UNNTheme { - Surface { - UserItem( - Modifier - .width(300.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - defUser, - Type.student.s - ) - } - } -} - -@Preview -@Composable -fun PostItemPreview() { - val post = Post( - id = 154923, - authorId = 165945, - enableComments = true, - numComments = 0, - date = LocalDateTime.of(2024, 3, 20, 18, 55, 20), - content = stringResource(id = R.string.lorem) - ) - - UNNTheme { - Surface { - PostItem( - Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), post - ) - } - } -} - - -class CoilImageGetter( - private val textView: TextView, - private val maxImageWidth: Int = textView.width -) : Html.ImageGetter { - - override fun getDrawable(source: String): Drawable { - val urlDrawable = UrlDrawable() - - if (maxImageWidth <= 0) - textView.post { updateImage(source, urlDrawable, textView.width) } - else - updateImage(source, urlDrawable, maxImageWidth) - - return urlDrawable - } - - private fun updateImage(source: String, urlDrawable: UrlDrawable, maxWidth: Int) { - val imageLoader = ImageLoader.Builder(textView.context) - .build() - - val request = ImageRequest.Builder(textView.context) - .data(source) - .target { drawable -> - val (scaledWidth, scaledHeight) = calculateScaledSize( - drawable.intrinsicWidth, - drawable.intrinsicHeight, - maxWidth - ) - - drawable.setBounds(0, 0, scaledWidth, scaledHeight) - urlDrawable.drawable = drawable - urlDrawable.setBounds(0, 0, scaledWidth, scaledHeight) - - textView.text = textView.text - } - .build() - - imageLoader.enqueue(request) - } - - private fun calculateScaledSize( - originalWidth: Int, - originalHeight: Int, - maxWidth: Int - ): Pair { - if (originalWidth <= maxWidth) - return Pair(originalWidth, originalHeight) - - val ratio = maxWidth.toFloat() / originalWidth.toFloat() - return Pair( - maxWidth, - (originalHeight * ratio).toInt() - ) - } -} - -class UrlDrawable() : Drawable() { - var drawable: Drawable? = null - set(value) { - field = value - invalidateSelf() - } - - override fun draw(canvas: android.graphics.Canvas) { - drawable?.draw(canvas) - } - - override fun setAlpha(alpha: Int) {} - override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {} - override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT -} \ No newline at end of file +//import android.graphics.drawable.Drawable +//import android.text.Html +//import android.text.method.LinkMovementMethod +//import android.text.util.Linkify +//import android.util.Log +//import android.widget.TextView +//import androidx.compose.foundation.background +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.Arrangement +//import androidx.compose.foundation.layout.Column +//import androidx.compose.foundation.layout.Row +//import androidx.compose.foundation.layout.fillMaxWidth +//import androidx.compose.foundation.layout.padding +//import androidx.compose.foundation.layout.size +//import androidx.compose.foundation.layout.width +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items +//import androidx.compose.foundation.shape.RoundedCornerShape +//import androidx.compose.material3.HorizontalDivider +//import androidx.compose.material3.LinearProgressIndicator +//import androidx.compose.material3.MaterialTheme +//import androidx.compose.material3.Surface +//import androidx.compose.material3.Text +//import androidx.compose.runtime.Composable +//import androidx.compose.runtime.LaunchedEffect +//import androidx.compose.runtime.NonRestartableComposable +//import androidx.compose.runtime.collectAsState +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.mutableIntStateOf +//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.draw.clip +//import androidx.compose.ui.graphics.toArgb +//import androidx.compose.ui.res.stringResource +//import androidx.compose.ui.text.font.FontStyle +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.tooling.preview.Preview +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.viewinterop.AndroidView +//import androidx.lifecycle.ViewModel +//import androidx.lifecycle.viewmodel.compose.viewModel +//import coil.ImageLoader +//import coil.compose.AsyncImage +//import coil.request.ImageRequest +//import kotlinx.coroutines.flow.MutableStateFlow +//import kotlinx.coroutines.flow.StateFlow +//import kotlinx.coroutines.flow.asStateFlow +//import ru.sweetbread.unn.R +//import ru.sweetbread.unn.api.AvatarSet +//import ru.sweetbread.unn.api.Post +//import ru.sweetbread.unn.api.Type +//import ru.sweetbread.unn.api.User +//import ru.sweetbread.unn.core.network.getBlogposts +//import ru.sweetbread.unn.core.network.getUserByBitrixId +//import ru.sweetbread.unn.core.network.portalURL +//import ru.sweetbread.unn.ui.theme.UNNTheme +//import java.time.LocalDate +//import java.time.LocalDateTime +//import java.time.format.DateTimeFormatter +//import java.time.format.FormatStyle +// +// +//val defUser = User( +// null, +// 123, +// Type.student, +// "cool.email@domain.com", +// "Джон Сигма Омегович", +// "Jon Sigma Omega", +// true, +// LocalDate.now(), +// 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" +// ) +//) +// +// +//@Composable +//fun Blogposts(viewModel: PostViewModel = viewModel()) { +// val posts by viewModel.posts.collectAsState() +// var extended by remember { mutableIntStateOf(0) } +// +// LaunchedEffect(Unit) { +// viewModel.loadPosts() +// } +// +// if (posts.isNotEmpty()) { +// Log.d("Another fuck", posts.size.toString()) +// LazyColumn { +// items(posts) { +// PostItem( +// Modifier +// .padding(8.dp) +// .clip(RoundedCornerShape(16.dp)) +// .background(MaterialTheme.colorScheme.secondaryContainer) +// .clickable { +// extended = if (extended == it.id) 0 else it.id +// }, +// post = it, +// extended == it.id +// ) +// } +// } +// } else { +// LinearProgressIndicator( +// modifier = Modifier +// .fillMaxWidth() +// .padding(8.dp), +// color = MaterialTheme.colorScheme.surfaceVariant, +// trackColor = MaterialTheme.colorScheme.secondary, +// ) +// } +//} +// +// +//class PostRepository { +// suspend fun loadPosts(): List { +// return getBlogposts() +// } +//} +// +//class PostViewModel : ViewModel() { +// private val repository = PostRepository() +// private val _posts = MutableStateFlow>(emptyList()) +// val posts: StateFlow> = _posts.asStateFlow() +// +// suspend fun loadPosts() { +// _posts.value = repository.loadPosts() +// } +//} +// +// +//@Composable +//@NonRestartableComposable +//fun UserItem(modifier: Modifier = Modifier, user: User, info: String? = null) { +// Row( +// modifier.padding(16.dp), +// verticalAlignment = Alignment.CenterVertically, +// horizontalArrangement = Arrangement.SpaceBetween +// ) { +// AsyncImage( +// modifier = Modifier +// .padding(end = 8.dp) +// .size(48.dp) +// .clip(RoundedCornerShape(50)), +// model = portalURL + user.avatar.thumbnail, +// contentDescription = user.nameEn +// ) +// +// Column { +// Text(user.nameRu, fontWeight = FontWeight.Bold) +// if (!info.isNullOrBlank()) +// Text( +// text = info, +// fontStyle = FontStyle.Italic, +// fontSize = MaterialTheme.typography.labelLarge.fontSize +// ) +// } +// } +//} +// +//@Composable +//@NonRestartableComposable +//fun PostItem(modifier: Modifier = Modifier, post: Post, extended: Boolean = false) { +// var user: User? by remember { mutableStateOf(null) } +// var html: String by remember { mutableStateOf("") } +// +// +// LaunchedEffect(post) { +// html = post.content +// user = getUserByBitrixId(post.authorId) +// } +// +// Column(modifier.padding(16.dp)) { +// if (user != null) +// UserItem(user = user!!) +// else +// LinearProgressIndicator( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 8.dp, vertical = 38.dp), +// color = MaterialTheme.colorScheme.surfaceVariant, +// trackColor = MaterialTheme.colorScheme.secondary, +// ) +// +// val textColor = MaterialTheme.colorScheme.onBackground.toArgb() +// val linkColor = MaterialTheme.colorScheme.primary.toArgb() +// +// AndroidView( +// modifier = Modifier, +// factory = { +// TextView(it).apply { +// movementMethod = LinkMovementMethod.getInstance() +// autoLinkMask = Linkify.WEB_URLS +// linksClickable = true +// setTextColor(textColor) +// setLinkTextColor(linkColor) +// } +// }, +// update = { +// it.maxLines = if (extended) Int.MAX_VALUE else 5 +// it.text = Html.fromHtml( +// html, +// Html.FROM_HTML_MODE_LEGACY, +// CoilImageGetter(it), +// null +// ) +// } +// ) +// +// HorizontalDivider( +// modifier = Modifier.padding(vertical = 16.dp), +// thickness = 1.dp, +// color = MaterialTheme.colorScheme.onBackground +// ) +// +// Text(text = post.date.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM))) +// } +//} +// +// +//@Preview +//@Composable +//fun UserItemPreview() { +// UNNTheme { +// Surface { +// UserItem( +// Modifier +// .width(300.dp) +// .clip(RoundedCornerShape(8.dp)) +// .background(MaterialTheme.colorScheme.primaryContainer), +// defUser, +// Type.student.s +// ) +// } +// } +//} +// +//@Preview +//@Composable +//fun PostItemPreview() { +// val post = Post( +// id = 154923, +// authorId = 165945, +// enableComments = true, +// numComments = 0, +// date = LocalDateTime.of(2024, 3, 20, 18, 55, 20), +// content = stringResource(id = R.string.lorem) +// ) +// +// UNNTheme { +// Surface { +// PostItem( +// Modifier +// .clip(RoundedCornerShape(8.dp)) +// .background(MaterialTheme.colorScheme.primaryContainer), post +// ) +// } +// } +//} +// +// +//class CoilImageGetter( +// private val textView: TextView, +// private val maxImageWidth: Int = textView.width +//) : Html.ImageGetter { +// +// override fun getDrawable(source: String): Drawable { +// val urlDrawable = UrlDrawable() +// +// if (maxImageWidth <= 0) +// textView.post { updateImage(source, urlDrawable, textView.width) } +// else +// updateImage(source, urlDrawable, maxImageWidth) +// +// return urlDrawable +// } +// +// private fun updateImage(source: String, urlDrawable: UrlDrawable, maxWidth: Int) { +// val imageLoader = ImageLoader.Builder(textView.context) +// .build() +// +// val request = ImageRequest.Builder(textView.context) +// .data(source) +// .target { drawable -> +// val (scaledWidth, scaledHeight) = calculateScaledSize( +// drawable.intrinsicWidth, +// drawable.intrinsicHeight, +// maxWidth +// ) +// +// drawable.setBounds(0, 0, scaledWidth, scaledHeight) +// urlDrawable.drawable = drawable +// urlDrawable.setBounds(0, 0, scaledWidth, scaledHeight) +// +// textView.text = textView.text +// } +// .build() +// +// imageLoader.enqueue(request) +// } +// +// private fun calculateScaledSize( +// originalWidth: Int, +// originalHeight: Int, +// maxWidth: Int +// ): Pair { +// if (originalWidth <= maxWidth) +// return Pair(originalWidth, originalHeight) +// +// val ratio = maxWidth.toFloat() / originalWidth.toFloat() +// return Pair( +// maxWidth, +// (originalHeight * ratio).toInt() +// ) +// } +//} +// +//class UrlDrawable() : Drawable() { +// var drawable: Drawable? = null +// set(value) { +// field = value +// invalidateSelf() +// } +// +// override fun draw(canvas: android.graphics.Canvas) { +// drawable?.draw(canvas) +// } +// +// override fun setAlpha(alpha: Int) {} +// override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {} +// override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT +//} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt b/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt index 870c100..0ca330d 100644 --- a/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt +++ b/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt @@ -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 @@ -41,9 +41,10 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import ru.sweetbread.unn.R -import ru.sweetbread.unn.api.auth +import ru.sweetbread.unn.UNNApp import ru.sweetbread.unn.ui.theme.UNNTheme import splitties.activities.start +import splitties.init.appCtx import splitties.preferences.Preferences object LoginData : Preferences("loginData") { @@ -55,9 +56,11 @@ class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val api = (application as UNNApp).container.portalApi + if ((LoginData.login != "") and (LoginData.password != "")) runBlocking { - if (auth()) { + if (api.auth()) { start() finish() } @@ -110,6 +113,7 @@ fun LoginPanel( ok: (login: String, password: String) -> Unit, error: () -> Unit ) { + val api = (appCtx as UNNApp).container.portalApi var login by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } var loading by remember { mutableStateOf(false) } @@ -148,7 +152,7 @@ fun LoginPanel( onClick = { loading = true scope.launch { - if (auth(login, password)) + if (api.auth(login, password)) ok(login, password) else error() diff --git a/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt b/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt index a19dd14..2531701 100644 --- a/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt +++ b/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -59,11 +60,10 @@ import androidx.navigation.compose.rememberNavController import coil.compose.AsyncImage import kotlinx.coroutines.launch import ru.sweetbread.unn.R -import ru.sweetbread.unn.api.ME -import ru.sweetbread.unn.api.portalURL +import ru.sweetbread.unn.UNNApp +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.Blogposts -import ru.sweetbread.unn.ui.composes.Schedule import ru.sweetbread.unn.ui.theme.UNNTheme import splitties.resources.appStr import splitties.toast.toast @@ -175,7 +175,8 @@ class MainActivity : ComponentActivity() { Box(Modifier.padding(innerPadding)) { NavHost(navController, startDestination = "journal/schedule") { composable("portal/blogposts") { - Blogposts() + Text("Not implemented") +// Blogposts() } composable("journal/schedule") { Schedule() @@ -194,87 +195,96 @@ class MainActivity : ComponentActivity() { @Composable fun DrawerContent(modifier: Modifier = Modifier, navController: NavController) { - Column(modifier = modifier.padding(0.dp)) { - 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 - ) + val me = (application as UNNApp).container.sessionManager.user.collectAsState().value - Column (Modifier.padding(horizontal = 8.dp)) { - Text( - ME.nameRu.split(" ").dropLast(1).joinToString(" "), - fontWeight = FontWeight.Bold + if (me != null) { + Column(modifier = modifier.padding(0.dp)) { + 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 ) - 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)) } } - - 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 { + Text("User not found") } } } \ No newline at end of file