From 44b0edf7214494423f5039e7bb3282bf681a7001 Mon Sep 17 00:00:00 2001 From: Sweetbread Date: Sun, 6 Sep 2026 20:42:32 +0300 Subject: [PATCH] refactor: rewrite code architecture --- app/build.gradle.kts | 43 +-- app/src/main/AndroidManifest.xml | 9 +- app/src/main/java/ru/sweetbread/unn/UNNApp.kt | 12 - .../main/java/ru/sweetbread/unn/api/API.kt | 95 ----- .../java/ru/sweetbread/unn/api/endpoints.kt | 255 ------------- .../unn/{ui/layout => app}/LoginActivity.kt | 51 +-- .../unn/{ui/layout => app}/MainActivity.kt | 246 ++++++++----- .../main/java/ru/sweetbread/unn/app/UNNApp.kt | 73 ++++ .../ru/sweetbread/unn/core/AuthRepository.kt | 53 +++ .../sweetbread/unn/core/CredentialsStore.kt | 152 ++++++++ .../ru/sweetbread/unn/core/SessionManager.kt | 34 ++ .../main/java/ru/sweetbread/unn/core/db/DB.kt | 43 +++ .../unn/core/db/dao/AuditoriumDao.kt | 22 ++ .../sweetbread/unn/core/db/dao/BuildingDao.kt | 22 ++ .../unn/core/db/dao/DisciplineDao.kt | 22 ++ .../unn/core/db/dao/KindOfWorkDao.kt | 22 ++ .../sweetbread/unn/core/db/dao/LecturerDao.kt | 22 ++ .../ru/sweetbread/unn/core/db/dao/NewsDao.kt | 33 ++ .../unn/core/db/dao/ScheduleItemDao.kt | 38 ++ .../ru/sweetbread/unn/core/db/dao/UserDao.kt | 21 ++ .../unn/core/db/entity/Auditorium.kt | 51 +++ .../sweetbread/unn/core/db/entity/Building.kt | 21 ++ .../unn/core/db/entity/Discipline.kt | 22 ++ .../unn/core/db/entity/KindOfWork.kt | 28 ++ .../sweetbread/unn/core/db/entity/Lecturer.kt | 31 ++ .../ru/sweetbread/unn/core/db/entity/News.kt | 34 ++ .../unn/core/db/entity/ScheduleUnit.kt | 70 ++++ .../ru/sweetbread/unn/core/db/entity/User.kt | 66 ++++ .../sweetbread/unn/core/network/PortalAPI.kt | 49 +++ .../ru/sweetbread/unn/core/network/client.kt | 80 ++++ .../sweetbread/unn/core/network/endpoints.kt | 206 +++++++++++ app/src/main/java/ru/sweetbread/unn/db/DB.kt | 29 -- .../java/ru/sweetbread/unn/db/Schedule.kt | 256 ------------- .../main/java/ru/sweetbread/unn/db/UserDB.kt | 99 ----- .../unn/{ui/composes => feature}/About.kt | 2 +- .../ru/sweetbread/unn/feature/news/News.kt | 259 +++++++++++++ .../unn/feature/news/NewsRemoteMediator.kt | 89 +++++ .../unn/feature/news/NewsRepository.kt | 48 +++ .../unn/feature/news/NewsViewModel.kt | 33 ++ .../unn/feature/profile/UserRepository.kt | 28 ++ .../composes => feature/schedule}/Schedule.kt | 174 ++++----- .../feature/schedule/ScheduleRepository.kt | 45 +++ .../unn/feature/schedule/ScheduleViewModel.kt | 91 +++++ .../schedule/components}/GroupsNameText.kt | 15 +- .../schedule/components}/Week.kt | 2 +- .../main/java/ru/sweetbread/unn/model/News.kt | 60 +++ .../unn/{api/models.kt => model/Schedule.kt} | 55 +-- .../main/java/ru/sweetbread/unn/model/User.kt | 113 ++++++ .../ru/sweetbread/unn/ui/composes/Blogpost.kt | 342 ------------------ build.gradle.kts | 13 +- gradle.properties | 30 +- gradle/libs.versions.toml | 75 ++-- gradle/wrapper/gradle-wrapper.properties | 5 +- settings.gradle.kts | 5 +- 54 files changed, 2311 insertions(+), 1483 deletions(-) delete mode 100644 app/src/main/java/ru/sweetbread/unn/UNNApp.kt delete mode 100644 app/src/main/java/ru/sweetbread/unn/api/API.kt delete mode 100644 app/src/main/java/ru/sweetbread/unn/api/endpoints.kt rename app/src/main/java/ru/sweetbread/unn/{ui/layout => app}/LoginActivity.kt (81%) rename app/src/main/java/ru/sweetbread/unn/{ui/layout => app}/MainActivity.kt (63%) create mode 100644 app/src/main/java/ru/sweetbread/unn/app/UNNApp.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/AuthRepository.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/CredentialsStore.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/DB.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/dao/AuditoriumDao.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/dao/BuildingDao.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/dao/DisciplineDao.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/dao/KindOfWorkDao.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/dao/LecturerDao.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/dao/NewsDao.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/dao/ScheduleItemDao.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/dao/UserDao.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/entity/Auditorium.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/entity/Building.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/entity/Discipline.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/entity/KindOfWork.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/entity/Lecturer.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/entity/News.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/entity/ScheduleUnit.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/db/entity/User.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/network/client.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt delete mode 100644 app/src/main/java/ru/sweetbread/unn/db/DB.kt delete mode 100644 app/src/main/java/ru/sweetbread/unn/db/Schedule.kt delete mode 100644 app/src/main/java/ru/sweetbread/unn/db/UserDB.kt rename app/src/main/java/ru/sweetbread/unn/{ui/composes => feature}/About.kt (96%) create mode 100644 app/src/main/java/ru/sweetbread/unn/feature/news/News.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/feature/news/NewsRemoteMediator.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/feature/news/NewsRepository.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/feature/news/NewsViewModel.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/feature/profile/UserRepository.kt rename app/src/main/java/ru/sweetbread/unn/{ui/composes => feature/schedule}/Schedule.kt (74%) create mode 100644 app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleRepository.kt create mode 100644 app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleViewModel.kt rename app/src/main/java/ru/sweetbread/unn/{ui/composes => feature/schedule/components}/GroupsNameText.kt (82%) rename app/src/main/java/ru/sweetbread/unn/{ui/composes => feature/schedule/components}/Week.kt (98%) create mode 100644 app/src/main/java/ru/sweetbread/unn/model/News.kt rename app/src/main/java/ru/sweetbread/unn/{api/models.kt => model/Schedule.kt} (58%) create mode 100644 app/src/main/java/ru/sweetbread/unn/model/User.kt delete mode 100644 app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt 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..72e4ec8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,13 +1,14 @@ - + + 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/ui/layout/LoginActivity.kt b/app/src/main/java/ru/sweetbread/unn/app/LoginActivity.kt similarity index 81% 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..f09c716 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,25 @@ 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 + + if (authRepo.hasSavedCredentials()) { + start() + finish() + return + } setContent { UNNTheme { @@ -77,12 +70,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 +101,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 +143,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 @@ -164,11 +159,3 @@ fun LoginPanel( } } } - -@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 63% 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..e1e423b 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,7 +1,10 @@ // Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). -package ru.sweetbread.unn.ui.layout +package ru.sweetbread.unn.app +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -41,6 +44,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 @@ -51,24 +55,33 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController 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.Job 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.LoginResult +import ru.sweetbread.unn.core.network.portalURL +import ru.sweetbread.unn.feature.About +import ru.sweetbread.unn.feature.news.Blogposts +import ru.sweetbread.unn.feature.schedule.Schedule import ru.sweetbread.unn.ui.theme.UNNTheme import splitties.resources.appStr import splitties.toast.toast class MainActivity : ComponentActivity() { + private lateinit var connectivityManager: ConnectivityManager + private var restoreSessionJob: Job? = null + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + restoreSavedSession() + } + } @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -76,9 +89,21 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) + val authRepo = (application as UNNApp).container.authRepo + lifecycleScope.launch { + authRepo.authenticationFailures.collect { + openLogin() + } + } + connectivityManager = getSystemService(ConnectivityManager::class.java) + restoreSavedSession() + 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() @@ -192,89 +217,136 @@ 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 - ) + override fun onStart() { + super.onStart() + connectivityManager.registerDefaultNetworkCallback(networkCallback) + } - Column (Modifier.padding(horizontal = 8.dp)) { - Text( - ME.nameRu.split(" ").dropLast(1).joinToString(" "), - fontWeight = FontWeight.Bold - ) - Text(ME.type.s) - } - } + override fun onStop() { + connectivityManager.unregisterNetworkCallback(networkCallback) + super.onStop() + } - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider() + private fun restoreSavedSession() { + val container = (application as UNNApp).container - TextButton( - {}, - Modifier.fillMaxWidth(), - shape = RectangleShape, - enabled = false - ) { - Text( - appStr(R.string.record_book), - Modifier.fillMaxWidth(), - textAlign = TextAlign.Start - ) - } + if ( + container.sessionManager.sessionId != null || + restoreSessionJob?.isActive == true + ) { + return + } - 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)) + restoreSessionJob = lifecycleScope.launch { + if (container.authRepo.restoreSession() == LoginResult.InvalidCredentials) { + openLogin() } } } -} \ No newline at end of file + + private fun openLogin() { + if (isFinishing) return + + startActivity( + Intent(this, LoginActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + finish() + } + + @Composable + fun DrawerContent(modifier: Modifier = Modifier, navController: NavController) { + val me = (application as UNNApp).container.sessionManager.user.collectAsState().value + + 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 + ) + + Column(Modifier.padding(horizontal = 8.dp)) { + Text( + me.nameRu.split(" ").dropLast(1).joinToString(" "), + fontWeight = FontWeight.Bold + ) + Text(me.type.s) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + + TextButton( + {}, + Modifier.fillMaxWidth(), + shape = RectangleShape, + enabled = false + ) { + Text( + appStr(R.string.record_book), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Start + ) + } + + TextButton( + {}, + Modifier.fillMaxWidth(), + shape = RectangleShape, + enabled = false + ) { + Text( + appStr(R.string.documents), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Start + ) + } + + TextButton( + {}, + Modifier.fillMaxWidth(), + shape = RectangleShape, + enabled = false + ) { + Text( + appStr(R.string.materials), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Start + ) + } + + Spacer(Modifier.weight(1f)) + + HorizontalDivider() + TextButton( + { navController.navigate("about") }, + Modifier.fillMaxWidth(), + shape = RectangleShape + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = "Info", + modifier = Modifier.padding(horizontal = 4.dp) + ) + Text(appStr(R.string.about_app)) + } + } + } else { + Text("User not found") + } + } +} 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..f108551 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/app/UNNApp.kt @@ -0,0 +1,73 @@ +// 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.LoginResult +import ru.sweetbread.unn.core.network.PortalApi +import ru.sweetbread.unn.core.network.SessionRefreshHandler +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 sessionRefreshHandler = SessionRefreshHandler() + + val httpClient = createHttpClient(sessionRefreshHandler) + + val portalApi = PortalApi( + client = httpClient, + session = sessionManager + ) + + val authRepo = AuthRepository(credentialsStore, portalApi) + + init { + sessionRefreshHandler.setRefreshAction { + authRepo.restoreSession() == LoginResult.Success + } + } + + 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, + ) +} 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..d8800b4 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/AuthRepository.kt @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). + +package ru.sweetbread.unn.core + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import ru.sweetbread.unn.core.network.LoginResult +import ru.sweetbread.unn.core.network.PortalApi + +class AuthRepository( + private val credentialsStore: CredentialsStore, + private val api: PortalApi +) { + private val restoreMutex = Mutex() + private val _authenticationFailures = MutableSharedFlow(extraBufferCapacity = 1) + val authenticationFailures = _authenticationFailures.asSharedFlow() + + 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 + } + + fun hasSavedCredentials(): Boolean = credentialsStore.get() != null + + suspend fun restoreSession(): LoginResult? = restoreMutex.withLock { + val credentials = credentialsStore.get() ?: return@withLock null + + val result = api.login( + credentials.login, + credentials.password + ) + + if (result == LoginResult.InvalidCredentials) { + credentialsStore.clear() + api.session.clear() + _authenticationFailures.emit(Unit) + } + + result + } +} 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..37e0bed --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/CredentialsStore.kt @@ -0,0 +1,152 @@ +// 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.GeneralSecurityException +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? { + return try { + 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) + null + else Credentials( + login = parts[0], + password = parts[1] + ) + } catch (_: GeneralSecurityException) { + clear() + null + } catch (_: IllegalArgumentException) { + clear() + null + } + } + + fun clear() { + prefs.edit { clear() } + } +} 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..339b0b8 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/db/dao/NewsDao.kt @@ -0,0 +1,33 @@ +// 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 + + @Query("DELETE FROM newsDB") + suspend fun clear() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(news: List) + + @Delete + suspend fun delete(unit: NewsDB) +} 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..ebf5344 --- /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.core.db.AppDatabase +import ru.sweetbread.unn.model.Auditorium +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..24277a9 --- /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.model.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)) 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..80631fa --- /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.model.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)) 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..ffa86e0 --- /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.model.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) +) 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..99dbc86 --- /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.model.Lecturer +import ru.sweetbread.unn.model.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..de19777 --- /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.core.db.AppDatabase +import ru.sweetbread.unn.model.ScheduleUnit +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() }) + } +} 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..2e71b54 --- /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.model.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..e5c5245 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/network/client.kt @@ -0,0 +1,80 @@ +// 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.HttpSend +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 +import io.ktor.client.plugins.plugin +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.http.HttpStatusCode +import io.ktor.util.AttributeKey + +private val SkipSessionRefresh = AttributeKey("SkipSessionRefresh") +private val SessionRefreshAttempted = AttributeKey("SessionRefreshAttempted") + +fun HttpRequestBuilder.skipSessionRefresh() { + attributes.put(SkipSessionRefresh, Unit) +} + +class SessionRefreshHandler { + @Volatile + private var refresh: suspend () -> Boolean = { false } + + fun setRefreshAction(action: suspend () -> Boolean) { + refresh = action + } + + suspend fun refreshSession(): Boolean = refresh() +} + +fun createHttpClient(sessionRefreshHandler: SessionRefreshHandler) = 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()) + } + } +}.apply { + plugin(HttpSend).intercept { request -> + val call = execute(request) + + if ( + call.response.status == HttpStatusCode.Forbidden && + request.attributes.getOrNull(SkipSessionRefresh) == null && + request.attributes.getOrNull(SessionRefreshAttempted) == null + ) { + request.attributes.put(SessionRefreshAttempted, Unit) + + if (sessionRefreshHandler.refreshSession()) { + execute(request) + } else { + call + } + } else { + call + } + } +} 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..3737a76 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt @@ -0,0 +1,206 @@ +// 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.core.SessionManager +import ru.sweetbread.unn.model.Auditorium +import ru.sweetbread.unn.model.Building +import ru.sweetbread.unn.model.Discipline +import ru.sweetbread.unn.model.KindOfWork +import ru.sweetbread.unn.model.Lecturer +import ru.sweetbread.unn.model.LecturerRank +import ru.sweetbread.unn.model.NewsDto +import ru.sweetbread.unn.model.ScheduleUnit +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() } + skipSessionRefresh() + } + + 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") { + skipSessionRefresh() + } + 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())) + } +} 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..c62b71c --- /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) + } + } +} 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 74% 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..f8fe112 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,33 +41,18 @@ 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 -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.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.model.Auditorium +import ru.sweetbread.unn.model.Building +import ru.sweetbread.unn.model.Discipline +import ru.sweetbread.unn.model.KindOfWork +import ru.sweetbread.unn.model.Lecturer +import ru.sweetbread.unn.model.LecturerRank +import ru.sweetbread.unn.model.ScheduleUnit 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..f836999 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/feature/schedule/ScheduleRepository.kt @@ -0,0 +1,45 @@ +// 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.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 ru.sweetbread.unn.model.ScheduleUnit +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) + } + } + } +} 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..75008c4 --- /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.model.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 + } + } + } +} 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 82% 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..e7fe6a9 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 @@ -27,7 +27,16 @@ fun GroupsNameText( ) { val suffix = if (text.endsWith("-В-OUP")) "-В-OUP" else null val mainPart = if (suffix != null) text.dropLast(6) else text - val blocks = mainPart.split('|') + val blocks = if (mainPart.startsWith("Поток_(") && mainPart.endsWith(')')) { + mainPart + .removePrefix("Поток_(") + .dropLast(1) + .split(';') + .map(String::trim) + .filter(String::isNotEmpty) + } else { + mainPart.split('|') + } FlowRow( modifier = modifier, @@ -47,4 +56,4 @@ fun GroupsNameText( suffix?.let { Text(text = it) } } -} \ No newline at end of file +} 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/api/models.kt b/app/src/main/java/ru/sweetbread/unn/model/Schedule.kt similarity index 58% rename from app/src/main/java/ru/sweetbread/unn/api/models.kt rename to app/src/main/java/ru/sweetbread/unn/model/Schedule.kt index 491a4df..47c655e 100644 --- a/app/src/main/java/ru/sweetbread/unn/api/models.kt +++ b/app/src/main/java/ru/sweetbread/unn/model/Schedule.kt @@ -1,28 +1,12 @@ // Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). -package ru.sweetbread.unn.api +package ru.sweetbread.unn.model 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), - SLecturer(R.string.slecturer), - AProfessor(R.string.aprofessor) -} - class ScheduleUnit( val oid: Int, val auditorium: Auditorium, @@ -32,9 +16,17 @@ class ScheduleUnit( val lecturers: List, val stream: String, val begin: LocalTime, - val end: LocalTime + val end: LocalTime, + val lastUpdated: LocalDateTime, ) +enum class LecturerRank(val id: Int) { + Assistant(R.string.assistant), + Lecturer(R.string.lecturer), + SLecturer(R.string.slecturer), + AProfessor(R.string.aprofessor) +} + class Auditorium( val name: String, val oid: Int, @@ -67,31 +59,4 @@ class Lecturer( val email: String, val unnId: Int, 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, - val enableComments: Boolean, - val numComments: Int, - val date: LocalDateTime, - val content: String -) - -class AvatarSet( - val original: String, - val thumbnail: String, - val small: String ) \ 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..21c2d2e --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/model/User.kt @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). + +package ru.sweetbread.unn.model + +import org.json.JSONObject +import ru.sweetbread.unn.R +import splitties.resources.appStr + +enum class Type(val s: String) { + Student(appStr(R.string.student)), + Lecturer(appStr(R.string.lecturer)), + 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 + ) +} + +class AvatarSet( + val original: String, + val thumbnail: String, + val small: String +) \ 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 {