diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 15fa898..b2e6839 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,26 +1,20 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
-import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Properties
plugins {
- alias(libs.plugins.androidApplication)
- alias(libs.plugins.jetbrainsKotlinAndroid)
+ alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
- id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
- id("com.google.devtools.ksp")
- id("io.sentry.android.gradle") version "5.5.0"
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.sentry.android)
}
-secrets {
- propertiesFileName = "secrets.properties"
-}
android {
namespace = "ru.sweetbread.unn"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
applicationId = "ru.sweetbread.unn"
@@ -73,16 +67,13 @@ android {
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
}
buildFeatures {
compose = true
- viewBinding = true
- buildConfig = true
- }
- composeOptions {
- kotlinCompilerExtensionVersion = "1.5.1"
+ resValues = true
}
packaging {
resources {
@@ -91,18 +82,16 @@ android {
}
}
-kotlin {
- compilerOptions {
- jvmTarget = JvmTarget.JVM_1_8
- }
-}
base {
archivesName = "ru.sweetbread.unn-v${android.defaultConfig.versionCode}(${android.defaultConfig.versionName})"
}
dependencies {
- implementation(libs.androidx.material.icons.core.android)
+ implementation(libs.androidx.material.icons.core)
+ implementation(libs.androidx.paging.common)
+ implementation(libs.androidx.paging.compose)
+ implementation(libs.androidx.paging.room)
coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(libs.androidx.core.ktx)
@@ -114,12 +103,8 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
- implementation(libs.androidx.appcompat)
- implementation(libs.material)
- implementation(libs.androidx.annotation)
- implementation(libs.androidx.constraintlayout)
- implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -130,10 +115,10 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.ktor.client.core)
- implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.android)
implementation(libs.coil.compose)
+ implementation(libs.coil.network.ktor3)
implementation(libs.androidx.datastore.preferences)
implementation(libs.splitties.base)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4675019..f277ff2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,7 +7,7 @@
diff --git a/app/src/main/java/ru/sweetbread/unn/UNNApp.kt b/app/src/main/java/ru/sweetbread/unn/UNNApp.kt
deleted file mode 100644
index 8cd5ff8..0000000
--- a/app/src/main/java/ru/sweetbread/unn/UNNApp.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
-
-package ru.sweetbread.unn
-
-import android.app.Application
-import android.content.Context
-
-class UNNApp : Application() {
- override fun attachBaseContext(base: Context) {
- super.attachBaseContext(base)
- }
-}
\ 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/ui/layout/LoginActivity.kt b/app/src/main/java/ru/sweetbread/unn/app/LoginActivity.kt
similarity index 82%
rename from app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt
rename to app/src/main/java/ru/sweetbread/unn/app/LoginActivity.kt
index 870c100..f0d5440 100644
--- a/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt
+++ b/app/src/main/java/ru/sweetbread/unn/app/LoginActivity.kt
@@ -1,6 +1,6 @@
-// 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.app
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -36,32 +36,28 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.tooling.preview.Preview
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.core.AuthRepository
import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.activities.start
-import splitties.preferences.Preferences
-object LoginData : Preferences("loginData") {
- var login by stringPref("login", "")
- var password by stringPref("password", "")
-}
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- if ((LoginData.login != "") and (LoginData.password != ""))
- runBlocking {
- if (auth()) {
- start()
- finish()
- }
+ val authRepo = (application as UNNApp).container.authRepo
+
+
+ runBlocking {
+ if (authRepo.restoreSession()) {
+ start()
+ finish()
}
+ }
setContent {
UNNTheme {
@@ -77,12 +73,13 @@ class LoginActivity : ComponentActivity() {
SnackbarHost(hostState = snackbarHostState)
}
) { innerPadding ->
- Box(Modifier.padding(innerPadding).fillMaxSize(), Alignment.Center) {
+ Box(Modifier
+ .padding(innerPadding)
+ .fillMaxSize(), Alignment.Center) {
LoginPanel(
Modifier.imePadding(),
- { login, password ->
- LoginData.login = login
- LoginData.password = password
+ authRepo,
+ {
start()
finish()
},
@@ -107,7 +104,8 @@ class LoginActivity : ComponentActivity() {
@Composable
fun LoginPanel(
modifier: Modifier = Modifier,
- ok: (login: String, password: String) -> Unit,
+ authRepo: AuthRepository,
+ ok: () -> Unit,
error: () -> Unit
) {
var login by rememberSaveable { mutableStateOf("") }
@@ -148,8 +146,8 @@ fun LoginPanel(
onClick = {
loading = true
scope.launch {
- if (auth(login, password))
- ok(login, password)
+ if (authRepo.login(login, password))
+ ok()
else
error()
loading = false
@@ -163,12 +161,4 @@ fun LoginPanel(
Text(stringResource(R.string.sign_in))
}
}
-}
-
-@Preview(showBackground = true)
-@Composable
-fun LoginPreview() {
- UNNTheme {
- LoginPanel(ok = { _, _ -> }, error = {})
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt b/app/src/main/java/ru/sweetbread/unn/app/MainActivity.kt
similarity index 72%
rename from app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt
rename to app/src/main/java/ru/sweetbread/unn/app/MainActivity.kt
index a19dd14..a5b6257 100644
--- a/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt
+++ b/app/src/main/java/ru/sweetbread/unn/app/MainActivity.kt
@@ -1,6 +1,4 @@
-// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
-
-package ru.sweetbread.unn.ui.layout
+package ru.sweetbread.unn.app
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -41,6 +39,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
@@ -56,14 +55,13 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
-import coil.compose.AsyncImage
+import coil3.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.ui.composes.About
-import ru.sweetbread.unn.ui.composes.Blogposts
-import ru.sweetbread.unn.ui.composes.Schedule
+import ru.sweetbread.unn.core.network.portalURL
+import ru.sweetbread.unn.feature.news.Blogposts
+import ru.sweetbread.unn.feature.schedule.Schedule
+import ru.sweetbread.unn.feature.About
import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.resources.appStr
import splitties.toast.toast
@@ -78,7 +76,10 @@ class MainActivity : ComponentActivity() {
setContent {
UNNTheme {
- Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -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
diff --git a/app/src/main/java/ru/sweetbread/unn/app/UNNApp.kt b/app/src/main/java/ru/sweetbread/unn/app/UNNApp.kt
new file mode 100644
index 0000000..2e2df7b
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/app/UNNApp.kt
@@ -0,0 +1,64 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.app
+
+import android.app.Application
+import android.content.Context
+import ru.sweetbread.unn.core.AuthRepository
+import ru.sweetbread.unn.core.CredentialsStore
+import ru.sweetbread.unn.core.SessionManager
+import ru.sweetbread.unn.core.db.AppDatabase
+import ru.sweetbread.unn.core.network.PortalApi
+import ru.sweetbread.unn.core.network.createHttpClient
+import ru.sweetbread.unn.feature.news.NewsRepository
+import ru.sweetbread.unn.feature.profile.UserRepository
+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 credentialsStore = CredentialsStore(context)
+ val sessionManager = SessionManager()
+
+ val httpClient = createHttpClient()
+
+ val portalApi = PortalApi(
+ client = httpClient,
+ session = sessionManager
+ )
+
+ val authRepo = AuthRepository(credentialsStore, portalApi)
+
+ val database = roomDb(name = "cache") {
+ fallbackToDestructiveMigration(dropAllTables = true)
+ }
+
+ val userRepository = UserRepository(
+ portalApi,
+ database,
+ )
+ val newsRepository = NewsRepository(
+ portalApi,
+ database,
+ sessionManager,
+ userRepository
+ )
+ val scheduleRepository = ScheduleRepository(
+ api = portalApi,
+ db = database,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/AuthRepository.kt b/app/src/main/java/ru/sweetbread/unn/core/AuthRepository.kt
new file mode 100644
index 0000000..b027c61
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/AuthRepository.kt
@@ -0,0 +1,46 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core
+
+import android.util.Log
+import kotlinx.io.IOException
+import ru.sweetbread.unn.core.network.LoginResult
+import ru.sweetbread.unn.core.network.PortalApi
+
+class AuthRepository(
+ private val credentialsStore: CredentialsStore,
+ private val api: PortalApi
+) {
+ suspend fun login(
+ login: String,
+ password: String
+ ): Boolean {
+ val res = api.login(login, password) == LoginResult.Success
+
+ if (res) {
+ credentialsStore.save(
+ Credentials(login, password)
+ )
+ }
+
+ return res
+ }
+
+ suspend fun restoreSession(): Boolean {
+ val credentials =
+ credentialsStore.get()
+ ?: return false
+
+ return try {
+ val res = api.login(
+ credentials.login,
+ credentials.password
+ )
+
+ res != LoginResult.InvalidCredentials
+ } catch (e: IOException) {
+ Log.e("AuthRepo", "restoreSession error", e)
+ false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/CredentialsStore.kt b/app/src/main/java/ru/sweetbread/unn/core/CredentialsStore.kt
new file mode 100644
index 0000000..b1f41c2
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/CredentialsStore.kt
@@ -0,0 +1,144 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import androidx.core.content.edit
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+
+data class Credentials(
+ val login: String,
+ val password: String
+)
+
+class CredentialsStore(
+ context: Context
+) {
+ private val prefs = context.getSharedPreferences(
+ "credentials",
+ Context.MODE_PRIVATE
+ )
+
+ private val keyAlias = "credentials_key"
+
+ private val key: SecretKey
+ get() {
+ val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
+ load(null)
+ }
+
+ val existingKey = keyStore.getKey(
+ keyAlias,
+ null
+ ) as? SecretKey
+
+ if (existingKey != null)
+ return existingKey
+
+ val keyGenerator = KeyGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_AES,
+ "AndroidKeyStore"
+ )
+
+ keyGenerator.init(
+ KeyGenParameterSpec.Builder(
+ keyAlias,
+ KeyProperties.PURPOSE_ENCRYPT or
+ KeyProperties.PURPOSE_DECRYPT
+ ).setBlockModes(
+ KeyProperties.BLOCK_MODE_GCM
+ ).setEncryptionPaddings(
+ KeyProperties.ENCRYPTION_PADDING_NONE
+ ).setKeySize(256)
+ .build()
+ )
+
+ return keyGenerator.generateKey()
+ }
+
+ fun save(credentials: Credentials) {
+ val plainText =
+ "${credentials.login}\u0000${credentials.password}"
+ .encodeToByteArray()
+
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+
+ cipher.init(
+ Cipher.ENCRYPT_MODE,
+ key
+ )
+
+ val encrypted = cipher.doFinal(plainText)
+
+ prefs.edit {
+ putString(
+ "data",
+ Base64.encodeToString(
+ encrypted,
+ Base64.NO_WRAP
+ )
+ )
+ putString(
+ "iv",
+ Base64.encodeToString(
+ cipher.iv,
+ Base64.NO_WRAP
+ )
+ )
+ }
+ }
+
+ fun get(): Credentials? {
+ val encryptedString =
+ prefs.getString("data", null)
+ ?: return null
+
+ val ivString =
+ prefs.getString("iv", null)
+ ?: return null
+
+ val encrypted = Base64.decode(
+ encryptedString,
+ Base64.NO_WRAP
+ )
+
+ val iv = Base64.decode(
+ ivString,
+ Base64.NO_WRAP
+ )
+
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+
+ cipher.init(
+ Cipher.DECRYPT_MODE,
+ key,
+ GCMParameterSpec(128, iv)
+ )
+
+ val plainText = cipher
+ .doFinal(encrypted)
+ .decodeToString()
+
+ val parts = plainText.split('\u0000', limit = 2)
+
+ if (parts.size != 2)
+ return null
+
+ return Credentials(
+ login = parts[0],
+ password = parts[1]
+ )
+ }
+
+ fun clear() {
+ prefs.edit { clear() }
+ }
+}
\ No newline at end of file
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..2522bd9
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt
@@ -0,0 +1,34 @@
+// 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 uns: Int? = null
+ private set
+
+ fun update(
+ user: User,
+ sessionId: String,
+ uns: Int
+ ) {
+ this._user.value = user
+ this.sessionId = sessionId
+ this.uns = uns
+ }
+
+ fun clear() {
+ _user.value = null
+ sessionId = null
+ uns = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/DB.kt b/app/src/main/java/ru/sweetbread/unn/core/db/DB.kt
new file mode 100644
index 0000000..2493d44
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/DB.kt
@@ -0,0 +1,43 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import ru.sweetbread.unn.core.db.dao.AuditoriumDao
+import ru.sweetbread.unn.core.db.dao.BuildingDao
+import ru.sweetbread.unn.core.db.dao.DisciplineDao
+import ru.sweetbread.unn.core.db.dao.KindOfWorkDao
+import ru.sweetbread.unn.core.db.dao.LecturerDao
+import ru.sweetbread.unn.core.db.dao.NewsDao
+import ru.sweetbread.unn.core.db.dao.ScheduleItemDao
+import ru.sweetbread.unn.core.db.dao.UserDao
+import ru.sweetbread.unn.core.db.entity.AuditoriumDB
+import ru.sweetbread.unn.core.db.entity.BuildingDB
+import ru.sweetbread.unn.core.db.entity.DisciplineDB
+import ru.sweetbread.unn.core.db.entity.KindOfWorkDB
+import ru.sweetbread.unn.core.db.entity.LecturerDB
+import ru.sweetbread.unn.core.db.entity.NewsDB
+import ru.sweetbread.unn.core.db.entity.ScheduleUnitDB
+import ru.sweetbread.unn.core.db.entity.UserDB
+
+@Database(entities = [
+ UserDB::class,
+ BuildingDB::class,
+ AuditoriumDB::class,
+ DisciplineDB::class,
+ KindOfWorkDB::class,
+ LecturerDB::class,
+ ScheduleUnitDB::class,
+ NewsDB::class
+], version = 3)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun userDao(): UserDao
+ abstract fun scheduleDao(): ScheduleItemDao
+ abstract fun auditoriumDao(): AuditoriumDao
+ abstract fun buildingDao(): BuildingDao
+ abstract fun disciplineDao(): DisciplineDao
+ abstract fun kindOfWorkDao(): KindOfWorkDao
+ abstract fun lecturerDao(): LecturerDao
+ abstract fun newsDao(): NewsDao
+}
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/dao/AuditoriumDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/dao/AuditoriumDao.kt
new file mode 100644
index 0000000..2778249
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/AuditoriumDao.kt
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import ru.sweetbread.unn.core.db.entity.AuditoriumDB
+
+@Dao
+interface AuditoriumDao {
+ @Query("SELECT * FROM auditoriumdb WHERE oid = :oid LIMIT 1")
+ suspend fun get(oid: Int): AuditoriumDB?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(auditoriums: List)
+
+ @Delete
+ fun delete(auditorium: AuditoriumDB)
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/dao/BuildingDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/dao/BuildingDao.kt
new file mode 100644
index 0000000..28f460f
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/BuildingDao.kt
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import ru.sweetbread.unn.core.db.entity.BuildingDB
+
+@Dao
+interface BuildingDao {
+ @Query("SELECT * FROM buildingdb WHERE oid = :oid LIMIT 1")
+ suspend fun get(oid: Int): BuildingDB?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(buildings: List)
+
+ @Delete
+ fun delete(building: BuildingDB)
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/dao/DisciplineDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/dao/DisciplineDao.kt
new file mode 100644
index 0000000..e3dbb24
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/DisciplineDao.kt
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import ru.sweetbread.unn.core.db.entity.DisciplineDB
+
+@Dao
+interface DisciplineDao {
+ @Query("SELECT * FROM disciplinedb WHERE oid = :oid LIMIT 1")
+ suspend fun get(oid: Int): DisciplineDB?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(disciplines: List)
+
+ @Delete
+ fun delete(discipline: DisciplineDB)
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/dao/KindOfWorkDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/dao/KindOfWorkDao.kt
new file mode 100644
index 0000000..2ea72c3
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/KindOfWorkDao.kt
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import ru.sweetbread.unn.core.db.entity.KindOfWorkDB
+
+@Dao
+interface KindOfWorkDao {
+ @Query("SELECT * FROM kindofworkdb WHERE oid = :oid LIMIT 1")
+ suspend fun get(oid: Int): KindOfWorkDB?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(kindOfWork: List)
+
+ @Delete
+ fun delete(kindOfWork: KindOfWorkDB)
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/dao/LecturerDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/dao/LecturerDao.kt
new file mode 100644
index 0000000..472929f
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/LecturerDao.kt
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import ru.sweetbread.unn.core.db.entity.LecturerDB
+
+@Dao
+interface LecturerDao {
+ @Query("SELECT * FROM lecturerdb WHERE unnId = :unnId LIMIT 1")
+ suspend fun get(unnId: Int): LecturerDB?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(lecturers: List)
+
+ @Delete
+ fun delete(lecturer: LecturerDB)
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/dao/NewsDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/dao/NewsDao.kt
new file mode 100644
index 0000000..ac5088a
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/NewsDao.kt
@@ -0,0 +1,30 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.dao
+
+import androidx.paging.PagingSource
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+import ru.sweetbread.unn.core.db.entity.NewsDB
+
+@Dao
+interface NewsDao {
+ @Query("SELECT * FROM newsDB WHERE id = :id LIMIT 1")
+ suspend fun get(id: Int): NewsDB?
+
+ @Query("SELECT * FROM newsDB ORDER BY `publishedAt` DESC")
+ fun getFlow(): Flow>
+
+ @Query("SELECT * FROM newsDB ORDER BY `publishedAt` DESC")
+ fun paging(): PagingSource
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(news: List)
+
+ @Delete
+ suspend fun delete(unit: NewsDB)
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/dao/ScheduleItemDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/dao/ScheduleItemDao.kt
new file mode 100644
index 0000000..280b5ef
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/ScheduleItemDao.kt
@@ -0,0 +1,38 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+import ru.sweetbread.unn.core.db.entity.ScheduleUnitDB
+
+@Dao
+interface ScheduleItemDao {
+ @Query("SELECT * FROM scheduleUnitDB WHERE oid = :oid LIMIT 1")
+ suspend fun getSchedule(oid: Int): ScheduleUnitDB?
+
+ @Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
+ suspend fun getSchedule(date: String): List
+
+ @Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
+ fun getScheduleFlow(date: String): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(units: List)
+
+ @Delete
+ suspend fun delete(unit: ScheduleUnitDB)
+
+ @Query("DELETE FROM scheduleUnitDB WHERE date = :date")
+ 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
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/dao/UserDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/dao/UserDao.kt
new file mode 100644
index 0000000..54c8992
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/UserDao.kt
@@ -0,0 +1,21 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import ru.sweetbread.unn.core.db.entity.UserDB
+
+@Dao
+interface UserDao {
+ @Query("SELECT * FROM userDB WHERE userId = :userId LIMIT 1")
+ suspend fun get(userId: Int): UserDB?
+
+ @Insert
+ suspend fun insert(user: UserDB)
+
+ @Delete
+ fun delete(user: UserDB)
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/Auditorium.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/Auditorium.kt
new file mode 100644
index 0000000..439a98a
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/Auditorium.kt
@@ -0,0 +1,51 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.withTransaction
+import ru.sweetbread.unn.api.Auditorium
+import ru.sweetbread.unn.core.db.AppDatabase
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Entity
+data class AuditoriumDB(
+ @PrimaryKey val oid: Int,
+ @ColumnInfo val name: String,
+ @ColumnInfo val floor: Int?,
+ @ColumnInfo val buildingOid: Int,
+ @ColumnInfo val updatedAt: String
+)
+
+
+suspend fun AuditoriumDB.toModel(db: AppDatabase): Auditorium? {
+ return Auditorium(
+ name,
+ oid,
+ floor ?: 0,
+ db.buildingDao().get(buildingOid)?.toModel() ?: return null
+ )
+}
+
+fun Auditorium.toEntity() = AuditoriumDB(
+ oid,
+ name,
+ floor,
+ building.oid,
+ LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
+)
+
+suspend fun cacheAuditorium(db: AppDatabase, auditoriums: List) {
+ db.withTransaction {
+ db.buildingDao().insert(
+ auditoriums.map { it.building }.map { it.toEntity() }
+ )
+
+ db.auditoriumDao().insert(
+ auditoriums.map { it.toEntity() }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/Building.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/Building.kt
new file mode 100644
index 0000000..b93cb1b
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/Building.kt
@@ -0,0 +1,21 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import ru.sweetbread.unn.api.Building
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Entity
+data class BuildingDB(
+ @PrimaryKey val oid: Int,
+ @ColumnInfo val name: String,
+ @ColumnInfo val gid: Int,
+ @ColumnInfo val updatedAt: String
+)
+
+fun BuildingDB.toModel() = Building(name, gid, oid)
+fun Building.toEntity() = BuildingDB(oid, name, gid, LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME))
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/Discipline.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/Discipline.kt
new file mode 100644
index 0000000..2b15184
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/Discipline.kt
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import ru.sweetbread.unn.api.Discipline
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Entity
+data class DisciplineDB(
+ @PrimaryKey val oid: Int,
+ @ColumnInfo val name: String,
+ @ColumnInfo val type: Int,
+ @ColumnInfo val updatedAt: String
+)
+
+fun DisciplineDB.toModel() = Discipline(name, oid, type)
+fun Discipline.toEntity() = DisciplineDB(oid, name, type, LocalDateTime.now().format(
+ DateTimeFormatter.ISO_DATE_TIME))
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/KindOfWork.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/KindOfWork.kt
new file mode 100644
index 0000000..3ebd51e
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/KindOfWork.kt
@@ -0,0 +1,28 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import ru.sweetbread.unn.api.KindOfWork
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Entity
+data class KindOfWorkDB(
+ @PrimaryKey val oid: Int,
+ @ColumnInfo val name: String,
+ @ColumnInfo val uid: String,
+ @ColumnInfo val complexity: Int,
+ @ColumnInfo val updatedAt: String
+)
+
+fun KindOfWorkDB.toModel() = KindOfWork(name, oid, uid, complexity)
+fun KindOfWork.toEntity() = KindOfWorkDB(
+ oid,
+ name,
+ uid,
+ complexity,
+ LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
+)
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/Lecturer.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/Lecturer.kt
new file mode 100644
index 0000000..55495dc
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/Lecturer.kt
@@ -0,0 +1,31 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import ru.sweetbread.unn.api.Lecturer
+import ru.sweetbread.unn.api.LecturerRank
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Entity
+data class LecturerDB(
+ @PrimaryKey val unnId: Int,
+ @ColumnInfo val name: String,
+ @ColumnInfo val rank: LecturerRank,
+ @ColumnInfo val email: String,
+ @ColumnInfo val uid: String,
+ @ColumnInfo val updatedAt: String
+)
+
+fun LecturerDB.toModel() = Lecturer(name, rank, email, unnId, uid)
+fun Lecturer.toEntity() = LecturerDB(
+ unnId,
+ name,
+ rank,
+ email,
+ uid,
+ LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
+)
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/News.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/News.kt
new file mode 100644
index 0000000..3bafd97
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/News.kt
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import ru.sweetbread.unn.feature.profile.UserRepository
+import ru.sweetbread.unn.model.News
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Entity
+data class NewsDB(
+ @PrimaryKey val id: Int,
+ @ColumnInfo val publishedAt: String,
+ @ColumnInfo val content: String,
+ @ColumnInfo val authorId: Int
+)
+
+
+suspend fun NewsDB.toModel(userRepo: UserRepository): News = News(
+ id,
+ LocalDateTime.parse(publishedAt, DateTimeFormatter.ISO_DATE_TIME),
+ content,
+ userRepo.getByUserId(this.authorId),
+)
+
+fun News.toEntity(): NewsDB = NewsDB(
+ id,
+ publishedAt.format(DateTimeFormatter.ISO_DATE_TIME),
+ content,
+ author.userId
+)
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/ScheduleUnit.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/ScheduleUnit.kt
new file mode 100644
index 0000000..b9ef774
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/ScheduleUnit.kt
@@ -0,0 +1,70 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.withTransaction
+import ru.sweetbread.unn.api.ScheduleUnit
+import ru.sweetbread.unn.core.db.AppDatabase
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+
+@Entity
+data class ScheduleUnitDB(
+ @PrimaryKey val oid: Int,
+ @ColumnInfo val date: String,
+ @ColumnInfo val stream: String,
+ @ColumnInfo val begin: String,
+ @ColumnInfo val end: String,
+
+ @ColumnInfo val auditoriumOid: Int,
+ @ColumnInfo val disciplineOid: Int,
+ @ColumnInfo val kindOfWorkOid: Int,
+ @ColumnInfo val lecturersId: String,
+
+ @ColumnInfo val updatedAt: String
+)
+
+
+suspend fun ScheduleUnitDB.toModel(db: AppDatabase): ScheduleUnit? {
+ return ScheduleUnit(
+ oid,
+ db.auditoriumDao().get(auditoriumOid)?.toModel(db) ?: return null,
+ LocalDate.parse(date, DateTimeFormatter.ISO_DATE),
+ 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),
+ LocalDateTime.parse(updatedAt, DateTimeFormatter.ISO_DATE_TIME),
+ )
+}
+
+fun ScheduleUnit.toEntity() = ScheduleUnitDB(
+ oid,
+ date.format(DateTimeFormatter.ISO_DATE),
+ stream,
+ begin.format(DateTimeFormatter.ISO_TIME),
+ end.format(DateTimeFormatter.ISO_TIME),
+ auditorium.oid,
+ discipline.oid,
+ kindOfWork.oid,
+ lecturers.map { it.unnId }.joinToString(","),
+ LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
+)
+
+
+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() })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/User.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/User.kt
new file mode 100644
index 0000000..5ac5dbd
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/User.kt
@@ -0,0 +1,66 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.db.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import io.ktor.util.reflect.instanceOf
+import ru.sweetbread.unn.api.AvatarSet
+import ru.sweetbread.unn.model.Student
+import ru.sweetbread.unn.model.Type
+import ru.sweetbread.unn.model.User
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Entity
+data class UserDB(
+ @PrimaryKey val userId: Int,
+ @ColumnInfo val uns: Int?,
+ @ColumnInfo val group: String?,
+ @ColumnInfo val extId: Int?,
+ @ColumnInfo val type: Type,
+ @ColumnInfo val email: String,
+ @ColumnInfo val nameRu: String,
+ @ColumnInfo val nameEn: String,
+ @ColumnInfo val origAvatar: String,
+ @ColumnInfo val thumbAvatar: String,
+ @ColumnInfo val smallAvatar: String,
+ @ColumnInfo val updatedAt: String
+)
+
+
+fun User.toEntity(): UserDB = UserDB(
+ userId,
+ if (this.instanceOf(Student::class)) (this as Student).uns else null,
+ if (this.instanceOf(Student::class)) (this as Student).group else null,
+ null,
+ type,
+ email,
+ nameRu,
+ nameEn,
+ avatar.original,
+ avatar.thumbnail,
+ avatar.small,
+ LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
+)
+
+fun UserDB.toModel(): User {
+ val user = User(
+ userId,
+ type,
+ email,
+ nameRu,
+ nameEn,
+ AvatarSet(
+ origAvatar,
+ thumbAvatar,
+ smallAvatar
+ )
+ )
+
+// if (type == Type.student)
+// user = Student(uns!!, user, group!!)
+
+ return user
+}
\ 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..5468653
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.network
+
+import io.ktor.client.HttpClient
+import kotlinx.io.IOException
+import ru.sweetbread.unn.core.SessionManager
+
+
+class PortalApi (
+ val client: HttpClient,
+ val session: SessionManager
+) {
+ public val auth = Auth(client)
+ public val ruzapi = Ruzapi(client, session)
+ public val bitrix = Bitrix(client)
+
+ /**
+ * Authorize user by [login] and [password]
+ */
+ suspend fun login(
+ login: String,
+ password: String
+ ): LoginResult {
+ return try {
+ val id = auth.getToken(login, password)
+
+ if (id == null) {
+ LoginResult.InvalidCredentials
+ } else {
+ session.update(
+ user = bitrix.getCurrentUser(),
+ sessionId = id,
+ uns = login.drop(1).toInt()
+ )
+
+ LoginResult.Success
+ }
+ } catch (_: IOException) {
+ LoginResult.NetworkError
+ }
+ }
+}
+
+sealed interface LoginResult {
+ data object Success : LoginResult
+ data object InvalidCredentials : LoginResult
+ data object NetworkError : LoginResult
+}
\ 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..29721f9
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt
@@ -0,0 +1,201 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.core.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.retry
+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.NewsDto
+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,
+) {
+ /**
+ * 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)
+ }
+ ) { retry { noRetry() } }
+
+ 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,
+) {
+ suspend fun getCurrentUser(): User {
+ val r = client.get("$vuzapiURL/profile/current")
+ return User.fromProfile(JSONObject(r.bodyAsText()))
+ }
+
+ suspend fun getUserByID(userId: Int): User {
+ val r = client.get("$vuzapiURL/user/$userId")
+ return User(JSONObject(r.bodyAsText()))
+ }
+
+ suspend fun getNews(page: Int = 1, amount: Int = 20): ArrayList {
+ val r = client.get("$prtl2URL/news_.php") {
+ parameter("numpage", page)
+ parameter("perpage", amount)
+ }
+ return NewsDto.fromJsonArray(JSONArray(r.bodyAsText()))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/db/DB.kt b/app/src/main/java/ru/sweetbread/unn/db/DB.kt
deleted file mode 100644
index b37583f..0000000
--- a/app/src/main/java/ru/sweetbread/unn/db/DB.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
-
-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,
- BuildingDB::class,
- AuditoriumDB::class,
- DisciplineDB::class,
- KindOfWorkDB::class,
- LecturerDB::class,
- ScheduleUnitDB::class], version = 2)
-abstract class AppDatabase : RoomDatabase() {
- abstract fun userDao(): UserDao
- abstract fun scheduleDao(): ScheduleItemDao
- abstract fun auditoriumDao(): AuditoriumDao
- abstract fun buildingDao(): BuildingDao
- abstract fun disciplineDao(): DisciplineDao
- abstract fun kindOfWorkDao(): KindOfWorkDao
- abstract fun lecturerDao(): LecturerDao
-}
diff --git a/app/src/main/java/ru/sweetbread/unn/db/Schedule.kt b/app/src/main/java/ru/sweetbread/unn/db/Schedule.kt
deleted file mode 100644
index 6ebb49a..0000000
--- a/app/src/main/java/ru/sweetbread/unn/db/Schedule.kt
+++ /dev/null
@@ -1,256 +0,0 @@
-// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
-
-package ru.sweetbread.unn.db
-
-import androidx.room.ColumnInfo
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Entity
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.PrimaryKey
-import androidx.room.Query
-import kotlinx.coroutines.flow.Flow
-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 java.time.LocalDate
-import java.time.LocalDateTime
-import java.time.LocalTime
-import java.time.format.DateTimeFormatter
-
-@Entity
-data class BuildingDB(
- @PrimaryKey val oid: Int,
- @ColumnInfo val name: String,
- @ColumnInfo val gid: Int,
- @ColumnInfo val updatedAt: String
-)
-
-@Dao
-interface BuildingDao {
- @Query("SELECT * FROM buildingdb WHERE oid = :oid LIMIT 1")
- fun get(oid: Int): BuildingDB?
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(buildings: List)
-
- @Delete
- fun delete(building: BuildingDB)
-}
-
-fun BuildingDB.toModel() = Building(name, gid, oid)
-fun Building.toEntity() = BuildingDB(oid, name, gid, LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME))
-
-
-@Entity
-data class AuditoriumDB(
- @PrimaryKey val oid: Int,
- @ColumnInfo val name: String,
- @ColumnInfo val floor: Int?,
- @ColumnInfo val buildingOid: Int,
- @ColumnInfo val updatedAt: String
-)
-
-@Dao
-interface AuditoriumDao {
- @Query("SELECT * FROM auditoriumdb WHERE oid = :oid LIMIT 1")
- fun get(oid: Int): AuditoriumDB?
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(auditoriums: List)
-
- @Delete
- fun delete(auditorium: AuditoriumDB)
-}
-
-fun AuditoriumDB.toModel(): Auditorium? {
- return Auditorium(
- name,
- oid,
- floor ?: 0,
- cacheDb.buildingDao().get(buildingOid)?.toModel() ?: return null
- )
-}
-fun Auditorium.toEntity() = AuditoriumDB(
- oid,
- name,
- floor,
- building.oid,
- LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
-)
-
-suspend fun cacheAuditorium(auditoriums: List) {
- cacheDb.buildingDao().insert(
- auditoriums.map { it.building }.map { it.toEntity() }
- )
-
- cacheDb.auditoriumDao().insert(
- auditoriums.map { it.toEntity() }
- )
-}
-
-@Entity
-data class DisciplineDB(
- @PrimaryKey val oid: Int,
- @ColumnInfo val name: String,
- @ColumnInfo val type: Int,
- @ColumnInfo val updatedAt: String
-)
-
-@Dao
-interface DisciplineDao {
- @Query("SELECT * FROM disciplinedb WHERE oid = :oid LIMIT 1")
- fun get(oid: Int): DisciplineDB?
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(disciplines: List)
-
- @Delete
- fun delete(discipline: DisciplineDB)
-}
-
-fun DisciplineDB.toModel() = Discipline(name, oid, type)
-fun Discipline.toEntity() = DisciplineDB(oid, name, type, LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME))
-
-
-@Entity
-data class KindOfWorkDB(
- @PrimaryKey val oid: Int,
- @ColumnInfo val name: String,
- @ColumnInfo val uid: String,
- @ColumnInfo val complexity: Int,
- @ColumnInfo val updatedAt: String
-)
-
-@Dao
-interface KindOfWorkDao {
- @Query("SELECT * FROM kindofworkdb WHERE oid = :oid LIMIT 1")
- fun get(oid: Int): KindOfWorkDB?
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(kindOfWork: List)
-
- @Delete
- fun delete(kindOfWork: KindOfWorkDB)
-}
-
-fun KindOfWorkDB.toModel() = KindOfWork(name, oid, uid, complexity)
-fun KindOfWork.toEntity() = KindOfWorkDB(
- oid,
- name,
- uid,
- complexity,
- LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
-)
-
-
-@Entity
-data class LecturerDB(
- @PrimaryKey val unnId: Int,
- @ColumnInfo val name: String,
- @ColumnInfo val rank: LecturerRank,
- @ColumnInfo val email: String,
- @ColumnInfo val uid: String,
- @ColumnInfo val updatedAt: String
-)
-
-@Dao
-interface LecturerDao {
- @Query("SELECT * FROM lecturerdb WHERE unnId = :unnId LIMIT 1")
- fun get(unnId: Int): LecturerDB?
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(lecturers: List)
-
- @Delete
- fun delete(lecturer: LecturerDB)
-}
-
-fun LecturerDB.toModel() = Lecturer(name, rank, email, unnId, uid)
-fun Lecturer.toEntity() = LecturerDB(
- unnId,
- name,
- rank,
- email,
- uid,
- LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
-)
-
-
-@Entity
-data class ScheduleUnitDB(
- @PrimaryKey val oid: Int,
- @ColumnInfo val date: String,
- @ColumnInfo val stream: String,
- @ColumnInfo val begin: String,
- @ColumnInfo val end: String,
-
- @ColumnInfo val auditoriumOid: Int,
- @ColumnInfo val disciplineOid: Int,
- @ColumnInfo val kindOfWorkOid: Int,
- @ColumnInfo val lecturersId: String,
-
- @ColumnInfo val updatedAt: String
-)
-@Dao
-interface ScheduleItemDao {
- @Query("SELECT * FROM scheduleUnitDB WHERE oid = :oid LIMIT 1")
- fun getSchedule(oid: Int): ScheduleUnitDB?
-
- @Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
- fun getSchedule(date: String): List
-
- @Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
- fun getScheduleFlow(date: String): Flow>
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(units: List)
-
- @Delete
- fun delete(unit: ScheduleUnitDB)
-
- @Query("DELETE FROM scheduleUnitDB WHERE date = :date")
- fun deleteByDate(date: String)
-}
-
-fun ScheduleUnitDB.toModel(): ScheduleUnit? {
- return ScheduleUnit(
- oid,
- cacheDb.auditoriumDao().get(auditoriumOid)?.toModel() ?: 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 },
- stream,
- LocalTime.parse(begin, DateTimeFormatter.ISO_TIME),
- LocalTime.parse(end, DateTimeFormatter.ISO_TIME)
- )
-}
-fun ScheduleUnit.toEntity() = ScheduleUnitDB(
- oid,
- date.format(DateTimeFormatter.ISO_DATE),
- stream,
- begin.format(DateTimeFormatter.ISO_TIME),
- end.format(DateTimeFormatter.ISO_TIME),
- auditorium.oid,
- discipline.oid,
- kindOfWork.oid,
- lecturers.map { it.unnId }.joinToString(","),
- 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() })
-}
-fun loadSchedule(date: LocalDate): List =
- cacheDb.scheduleDao().getSchedule(date.format(DateTimeFormatter.ISO_DATE))
- .mapNotNull { it.toModel() }
diff --git a/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt b/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt
deleted file mode 100644
index c6f0a81..0000000
--- a/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (c) 2025 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
-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
-
-@Entity
-data class UserDB(
- @PrimaryKey val userId: Int,
- @ColumnInfo val unnId: Int?,
- @ColumnInfo val type: Type,
- @ColumnInfo val email: String,
- @ColumnInfo val nameRu: String,
- @ColumnInfo val nameEn: String,
- @ColumnInfo val isMale: Boolean,
- @ColumnInfo val birthday: String,
- @ColumnInfo val origAvatar: String,
- @ColumnInfo val thumbAvatar: String,
- @ColumnInfo val smallAvatar: String,
- @ColumnInfo val expiredAt: String
-)
-
-@Dao
-interface UserDao {
- @Query("SELECT * FROM userDB WHERE userId = :userId LIMIT 1")
- fun getUserById(userId: Int): UserDB?
-
- @Insert
- fun insert(user: UserDB)
-
- @Delete
- fun delete(user: UserDB)
-}
-
-
-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/About.kt b/app/src/main/java/ru/sweetbread/unn/feature/About.kt
similarity index 96%
rename from app/src/main/java/ru/sweetbread/unn/ui/composes/About.kt
rename to app/src/main/java/ru/sweetbread/unn/feature/About.kt
index ee4ada4..ab2e8e3 100644
--- a/app/src/main/java/ru/sweetbread/unn/ui/composes/About.kt
+++ b/app/src/main/java/ru/sweetbread/unn/feature/About.kt
@@ -1,6 +1,6 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
-package ru.sweetbread.unn.ui.composes
+package ru.sweetbread.unn.feature
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
diff --git a/app/src/main/java/ru/sweetbread/unn/feature/news/News.kt b/app/src/main/java/ru/sweetbread/unn/feature/news/News.kt
new file mode 100644
index 0000000..1c9a322
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/feature/news/News.kt
@@ -0,0 +1,259 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.feature.news
+
+import android.graphics.drawable.Drawable
+import android.text.Html
+import android.text.method.LinkMovementMethod
+import android.text.util.Linkify
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.compose.itemKey
+import coil3.ImageLoader
+import coil3.asDrawable
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import ru.sweetbread.unn.core.network.portalURL
+import ru.sweetbread.unn.model.News
+import ru.sweetbread.unn.model.User
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+
+@Composable
+fun Blogposts(viewModel: NewsViewModel = viewModel(factory = NewsViewModel.Factory)) {
+ val news = viewModel.news.collectAsLazyPagingItems()
+ var extended by remember { mutableIntStateOf(0) }
+
+ LazyColumn {
+ items(
+ count = news.itemCount,
+ key = news.itemKey { it.id }
+ ) {
+ news[it]?.let { post ->
+ PostItem(
+ Modifier
+ .padding(8.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.secondaryContainer)
+ .clickable {
+ extended = if (extended == post.id) 0 else post.id
+ },
+ post = post,
+ extended == post.id
+ )
+ }
+ }
+ }
+}
+
+@Composable
+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
+fun PostItem(modifier: Modifier = Modifier, post: News, extended: Boolean = false) {
+ var html: String by remember { mutableStateOf("") }
+
+
+ LaunchedEffect(post) {
+ html = post.content
+ }
+
+ Column(modifier.padding(16.dp)) {
+ UserItem(user = post.author)
+
+ 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.publishedAt.format(
+ DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
+ ))
+ }
+}
+
+
+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 { image ->
+ val drawable =
+ image.asDrawable(
+ textView.resources
+ )
+
+ val (width, height) =
+ calculateScaledSize(
+ drawable.intrinsicWidth,
+ drawable.intrinsicHeight,
+ maxWidth
+ )
+
+ drawable.setBounds(
+ 0, 0,
+ width, height
+ )
+
+ urlDrawable.drawable = drawable
+
+ urlDrawable.setBounds(
+ 0, 0,
+ width, height
+ )
+
+ // заставляем TextView заново нарисовать
+ textView.text = textView.text
+ }
+ .build()
+
+ imageLoader.enqueue(request)
+ }
+
+ private fun calculateScaledSize(
+ originalWidth: Int,
+ originalHeight: Int,
+ maxWidth: Int
+ ): Pair {
+ if (originalWidth <= maxWidth) {
+ return originalWidth to originalHeight
+ }
+
+ val ratio =
+ maxWidth.toFloat() / originalWidth
+
+ return maxWidth to
+ (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/feature/news/NewsRemoteMediator.kt b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRemoteMediator.kt
new file mode 100644
index 0000000..4b0d5c1
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRemoteMediator.kt
@@ -0,0 +1,89 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.feature.news
+
+import android.net.http.HttpException
+import android.os.Build
+import androidx.annotation.RequiresExtension
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.LoadType
+import androidx.paging.PagingState
+import androidx.paging.RemoteMediator
+import androidx.room.withTransaction
+import kotlinx.io.IOException
+import ru.sweetbread.unn.core.db.AppDatabase
+import ru.sweetbread.unn.core.db.entity.NewsDB
+import ru.sweetbread.unn.core.db.entity.toEntity
+import ru.sweetbread.unn.core.network.PortalApi
+import ru.sweetbread.unn.feature.profile.UserRepository
+import ru.sweetbread.unn.model.News
+
+
+@OptIn(ExperimentalPagingApi::class)
+class NewsRemoteMediator(
+ private val db: AppDatabase,
+ private val api: PortalApi,
+ private val userRepo: UserRepository
+) : RemoteMediator() {
+ private var nextPage = 1
+
+ @RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
+ @OptIn(ExperimentalPagingApi::class)
+ override suspend fun load(
+ loadType: LoadType,
+ state: PagingState
+ ): MediatorResult {
+ val page = when (loadType) {
+ LoadType.REFRESH -> {
+ nextPage = 1
+ 1
+ }
+
+ LoadType.PREPEND -> {
+ return MediatorResult.Success(
+ endOfPaginationReached = true
+ )
+ }
+
+ LoadType.APPEND -> { nextPage }
+ }
+
+ return try {
+ val response = api.bitrix.getNews(page)
+
+ val news = response.map { dto ->
+
+ val author = userRepo.getByUserId(dto.authorId)
+
+ News(
+ dto.id,
+ dto.publishedAt,
+ dto.content,
+ author
+ )
+ }
+
+ db.withTransaction {
+// if (loadType == LoadType.REFRESH) {
+// db.newsDao().clear()
+// }
+
+ db.newsDao().insertAll(
+ news.map { it.toEntity() }
+ )
+ }
+
+ nextPage = page + 1
+
+ MediatorResult.Success(
+ endOfPaginationReached =
+ news.size < 20
+ )
+
+ } catch (e: IOException) {
+ MediatorResult.Error(e)
+ } catch (e: HttpException) {
+ MediatorResult.Error(e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRepository.kt b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRepository.kt
new file mode 100644
index 0000000..b1c9f22
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRepository.kt
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.feature.news
+
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.map
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import ru.sweetbread.unn.core.SessionManager
+import ru.sweetbread.unn.core.db.AppDatabase
+import ru.sweetbread.unn.core.db.entity.toModel
+import ru.sweetbread.unn.core.network.PortalApi
+import ru.sweetbread.unn.feature.profile.UserRepository
+import ru.sweetbread.unn.model.News
+
+class NewsRepository (
+ val api: PortalApi,
+ val db: AppDatabase,
+ val session: SessionManager,
+ val userRepository: UserRepository
+) {
+ @OptIn(ExperimentalPagingApi::class)
+ fun getNews(): Flow> = Pager(
+ config = PagingConfig(
+ pageSize = 20,
+ initialLoadSize = 20,
+ prefetchDistance = 10,
+ enablePlaceholders = false
+ ),
+ remoteMediator = NewsRemoteMediator(
+ db,
+ api,
+ userRepository
+ ),
+ pagingSourceFactory = {
+ db.newsDao().paging()
+ }
+ )
+ .flow
+ .map { pagingData ->
+ pagingData.map { entity ->
+ entity.toModel(userRepository)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/feature/news/NewsViewModel.kt b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsViewModel.kt
new file mode 100644
index 0000000..434b6d6
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsViewModel.kt
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.feature.news
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import androidx.paging.cachedIn
+import ru.sweetbread.unn.app.UNNApp
+
+class NewsViewModel(
+ repository: NewsRepository
+) : ViewModel() {
+ val news = repository
+ .getNews()
+ .cachedIn(viewModelScope)
+
+ companion object {
+ val Factory = viewModelFactory {
+ initializer {
+ val application = checkNotNull(
+ this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
+ ) as UNNApp
+
+ NewsViewModel(
+ repository = application.container.newsRepository
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/sweetbread/unn/feature/profile/UserRepository.kt b/app/src/main/java/ru/sweetbread/unn/feature/profile/UserRepository.kt
new file mode 100644
index 0000000..2603b1e
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/feature/profile/UserRepository.kt
@@ -0,0 +1,28 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.feature.profile
+
+import android.util.Log
+import ru.sweetbread.unn.core.db.AppDatabase
+import ru.sweetbread.unn.core.db.entity.toEntity
+import ru.sweetbread.unn.core.db.entity.toModel
+import ru.sweetbread.unn.core.network.PortalApi
+import ru.sweetbread.unn.model.User
+
+class UserRepository (
+ val api: PortalApi,
+ val db: AppDatabase,
+) {
+ suspend fun getByUserId(id: Int): User {
+ if (id == 0) return User.getPlaceholder()
+
+ val fromDb = db.userDao().get(id)
+ if (fromDb == null) {
+ Log.d("user", "id: $id")
+ val fromApi = api.bitrix.getUserByID(id)
+ db.userDao().insert(fromApi.toEntity())
+ return fromApi
+ }
+ return fromDb.toModel()
+ }
+}
\ 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..b2c0f76 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.feature.schedule.components.BoundedWeekPicker
+import ru.sweetbread.unn.feature.schedule.components.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..1d304a4
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleRepository.kt
@@ -0,0 +1,46 @@
+// 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.db.AppDatabase
+import ru.sweetbread.unn.core.db.entity.cacheSchedule
+import ru.sweetbread.unn.core.db.entity.toModel
+import ru.sweetbread.unn.core.network.PortalApi
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+class ScheduleRepository (
+ val api: PortalApi,
+ val db: AppDatabase,
+) {
+ 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..ea60731
--- /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.app.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/ui/composes/GroupsNameText.kt b/app/src/main/java/ru/sweetbread/unn/feature/schedule/components/GroupsNameText.kt
similarity index 96%
rename from app/src/main/java/ru/sweetbread/unn/ui/composes/GroupsNameText.kt
rename to app/src/main/java/ru/sweetbread/unn/feature/schedule/components/GroupsNameText.kt
index d0a8116..3fe8dde 100644
--- a/app/src/main/java/ru/sweetbread/unn/ui/composes/GroupsNameText.kt
+++ b/app/src/main/java/ru/sweetbread/unn/feature/schedule/components/GroupsNameText.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.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
diff --git a/app/src/main/java/ru/sweetbread/unn/ui/composes/Week.kt b/app/src/main/java/ru/sweetbread/unn/feature/schedule/components/Week.kt
similarity index 98%
rename from app/src/main/java/ru/sweetbread/unn/ui/composes/Week.kt
rename to app/src/main/java/ru/sweetbread/unn/feature/schedule/components/Week.kt
index 3ab5f0f..25cf8bb 100644
--- a/app/src/main/java/ru/sweetbread/unn/ui/composes/Week.kt
+++ b/app/src/main/java/ru/sweetbread/unn/feature/schedule/components/Week.kt
@@ -1,5 +1,5 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
-package ru.sweetbread.unn.ui.composes
+package ru.sweetbread.unn.feature.schedule.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
diff --git a/app/src/main/java/ru/sweetbread/unn/model/News.kt b/app/src/main/java/ru/sweetbread/unn/model/News.kt
new file mode 100644
index 0000000..4b52fdb
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/model/News.kt
@@ -0,0 +1,60 @@
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
+
+package ru.sweetbread.unn.model
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+
+class News (
+ val id: Int,
+ val publishedAt: LocalDateTime,
+ val content: String,
+ val author: User
+)
+
+class NewsDto (
+ val id: Int,
+ val publishedAt: LocalDateTime,
+ val content: String,
+ val authorId: Int,
+ val authorLogin: String
+// val comments: ArrayList,
+// val important: Boolean,
+// val pinned: Boolean
+) {
+ constructor(json: JSONObject) : this (
+ json.getString("id").toInt(),
+ LocalDateTime.parse(
+ json.getString("time"),
+ DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss")
+ ),
+ json.getString("fulltext"),
+ json.getJSONObject("author").getString("user_id").toInt(),
+ json.getJSONObject("author").getString("login")
+// NewsComment.fromJsonArray(json.getJSONArray("comments")),
+// json.getBoolean("important"),
+// json.getBoolean("pinned")
+ )
+
+ companion object {
+ fun fromJsonArray(arr: JSONArray): ArrayList {
+ val out: ArrayList = arrayListOf()
+ for (i in 0 until arr.length()) {
+ out.add(NewsDto(arr.getJSONObject(i)))
+ }
+ return out
+ }
+ }
+}
+
+
+//class NewsComment() {
+// companion object {
+// fun fromJsonArray(json: JSONArray): ArrayList {
+//
+// }
+// }
+//}
\ 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..2061885
--- /dev/null
+++ b/app/src/main/java/ru/sweetbread/unn/model/User.kt
@@ -0,0 +1,110 @@
+// 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
+
+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))
+}
+
+open class User(
+ open val userId: Int, // vuzapi ID
+ // val bxId: Int, // Bitrix user ID
+ // val raId: Int?, // ruzapi ID (for schedule)
+ open val type: Type,
+ open val email: String,
+ open val nameRu: String,
+ open val nameEn: String,
+ open val avatar: AvatarSet
+) {
+ constructor(json: JSONObject) : this(
+ json.getInt("id"),
+ when (
+ json.getJSONArray("profiles")
+ .getJSONObject(0)
+ .getString("type")
+ ) {
+ "student" -> Type.student
+ else -> Type.employee
+ },
+ json.getString("email"),
+ json.getString("fullname"),
+ json.getString("fullname_en"),
+ json.getJSONObject("photo").let {
+ AvatarSet(
+ it.getString("orig"),
+ it.getString("thumbnail"),
+ it.getString("small"),
+ )
+ }
+ )
+
+ companion object {
+ fun fromProfile(json: JSONObject): User {
+ val user = json.getJSONObject("user")
+ return User(
+ user.getInt("id"),
+ when (json.getString("type")) {
+ "student" -> Type.student
+ else -> Type.employee
+ },
+ user.getString("email"),
+ user.getString("fullname"),
+ user.getString("fullname_en"),
+ user.getJSONObject("photo").let {
+ AvatarSet(
+ it.getString("orig"),
+ it.getString("thumbnail"),
+ it.getString("small"),
+ )
+ }
+ )
+ }
+
+ fun getPlaceholder() = User(
+ 0,
+ Type.employee,
+ "",
+ "Удалённый пользователь",
+ "Deleted user",
+ AvatarSet("", "", "")
+ )
+ }
+}
+
+class Student(
+ val uns: Int?,
+ override val userId: Int,
+ override val type: Type,
+ override val email: String,
+ override val nameRu: String,
+ override val nameEn: String,
+ override val avatar: AvatarSet,
+ val group: String
+) : User (
+ userId,
+ type,
+ email,
+ nameRu,
+ nameEn,
+ avatar,
+) {
+ constructor(uns: Int? = null, user: User, group: String) : this (
+ uns,
+ user.userId,
+ user.type,
+ user.email,
+ user.nameRu,
+ user.nameEn,
+ user.avatar,
+ group
+ )
+}
\ 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
deleted file mode 100644
index 26cc3d2..0000000
--- a/app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt
+++ /dev/null
@@ -1,342 +0,0 @@
-// Copyright (c) 2025 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
diff --git a/build.gradle.kts b/build.gradle.kts
index c84e1ef..b0aac75 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,15 +1,8 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- alias(libs.plugins.androidApplication) apply false
- alias(libs.plugins.jetbrainsKotlinAndroid) apply false
+ alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
- id("com.google.devtools.ksp") version "2.3.2" apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.sentry.android) apply false
}
-
-buildscript {
- dependencies {
- classpath(libs.secrets.gradle.plugin)
- }
-}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 416fc40..b7419b8 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,35 +1,7 @@
# Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
-# Specifies the JVM arguments used for the daemon process.
-# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. For more details, visit
-# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
-# org.gradle.parallel=true
-# AndroidX package structure to make it clearer which packages are bundled with the
-# Android operating system, and which are packaged with your app's APK
-# https://developer.android.com/topic/libraries/support-library/androidx-rn
+org.gradle.caching=true
android.useAndroidX=true
-# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
-# Enables namespacing of each library's R class so that its R class includes only the
-# resources declared in the library itself and none from the library's dependencies,
-# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
-android.defaults.buildfeatures.resvalues=true
-android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
-android.enableAppCompileTimeRClass=false
-android.usesSdkInManifest.disallowed=false
-android.uniquePackageNames=false
-android.dependency.useConstraints=true
-android.r8.strictFullModeForKeepRules=false
-android.r8.optimizedResourceShrinking=false
-android.builtInKotlin=false
-android.newDsl=false
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0f2e409..c8a5ce4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,70 +1,65 @@
[versions]
-agp = "9.0.1"
-coilCompose = "2.7.0"
-compose = "1.10.3"
+agp = "9.3.2"
+coil = "3.6.2"
coreSplashscreen = "1.2.0"
-datastorePreferences = "1.2.0"
-desugar_jdk_libs = "2.1.5"
-kotlin = "2.3.10"
-coreKtx = "1.17.0"
+datastorePreferences = "1.2.1"
+desugarJdkLibs = "2.1.5"
+kotlin = "2.4.20"
+ksp = "2.3.11"
+coreKtx = "1.19.0"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
-ktor = "3.4.0"
-lifecycle = "2.10.0"
-activityCompose = "1.12.4"
-composeBom = "2026.02.00"
-appcompat = "1.7.1"
-material = "1.13.0"
-annotation = "1.9.1"
-constraintlayout = "2.2.1"
-activity = "1.12.4"
-navigationCompose = "2.9.7"
-roomRuntime = "2.8.4"
-secretsGradlePlugin = "2.0.1"
-sentryAndroid = "8.32.0"
+ktor = "3.5.2"
+lifecycle = "2.11.0"
+activity = "1.13.0"
+composeBom = "2026.08.00"
+navigationCompose = "2.10.0"
+room = "2.8.4"
+sentryAndroid = "8.55.0"
+sentryGradlePlugin = "6.19.0"
splitties = "3.0.0"
-materialIconsCoreAndroid = "1.7.8"
+paging = "3.5.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
-androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" }
-androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" }
-androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
-coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
-desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
+coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
+desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
-androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
+androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
-androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "compose" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
-androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
-ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
-androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" }
-androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
-androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
-androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
-androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
-secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secretsGradlePlugin" }
sentry = { module = "io.sentry:sentry-android", version.ref = "sentryAndroid" }
splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl", version.ref = "splitties" }
splitties-room = { module = "com.louiscad.splitties:splitties-arch-room", version.ref = "splitties" }
-androidx-material-icons-core-android = { group = "androidx.compose.material", name = "material-icons-core-android", version.ref = "materialIconsCoreAndroid" }
+androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" }
+androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
+androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
+androidx-paging-room = { module = "androidx.room:room-paging", version.ref = "room" }
[plugins]
-androidApplication = { id = "com.android.application", version.ref = "agp" }
-jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+sentry-android = { id = "io.sentry.android.gradle", version.ref = "sentryGradlePlugin" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 8b69aae..2f9712c 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,8 +1,9 @@
# Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
-#Sat Mar 16 18:30:45 MSK 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 74494ae..037c91e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,4 +1,4 @@
-// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
+// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
pluginManagement {
repositories {
@@ -13,6 +13,9 @@ pluginManagement {
gradlePluginPortal()
}
}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {