Compare commits

9 Commits
Author SHA1 Message Date
Sweetbread 9e94a67a85 fix: network connectivity related issues 2026-09-17 14:11:34 +03:00
Sweetbread b7183805c2 fix: download recent news 2026-09-17 13:47:10 +03:00
Sweetbread ad64fdf4c5 feat: update GroupsNameText.kt to the new API 2026-09-17 13:07:02 +03:00
Sweetbread d66ffaae7b ref: models.kt 2026-09-17 13:01:54 +03:00
Sweetbread e9151f281b ref: remove files from ui 2026-09-16 11:54:00 +03:00
Sweetbread 05ec933b15 chore: cleanup 2026-09-15 15:51:49 +03:00
Sweetbread bf530b6214 ref: db 2026-09-12 00:29:36 +03:00
Sweetbread b7647d7267 ref: blogposts 2026-09-11 01:56:10 +03:00
Sweetbread 5b76f55cb7 deps: update 2026-09-09 15:20:02 +03:00
50 changed files with 1525 additions and 1251 deletions
+14 -29
View File
@@ -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)
+5 -4
View File
@@ -1,13 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<!-- Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".UNNApp"
android:name=".app.UNNApp"
android:allowBackup="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
@@ -45,11 +46,11 @@
android:value="0.1" />
<activity
android:name=".ui.layout.MainActivity"
android:name=".app.MainActivity"
android:exported="true"
android:theme="@style/Theme.UNN" />
<activity
android:name=".ui.layout.LoginActivity"
android:name=".app.LoginActivity"
android:exported="true"
android:theme="@style/Theme.UNN">
<intent-filter>
@@ -1,6 +1,6 @@
// 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
@@ -38,9 +38,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.sweetbread.unn.R
import ru.sweetbread.unn.UNNApp
import ru.sweetbread.unn.core.AuthRepository
import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.activities.start
@@ -52,12 +50,10 @@ class LoginActivity : ComponentActivity() {
val authRepo = (application as UNNApp).container.authRepo
runBlocking {
if (authRepo.restoreSession()) {
start<MainActivity>()
finish()
}
if (authRepo.hasSavedCredentials()) {
start<MainActivity>()
finish()
return
}
setContent {
@@ -163,11 +159,3 @@ fun LoginPanel(
}
}
}
//@Preview(showBackground = true)
//@Composable
//fun LoginPreview() {
// UNNTheme {
// LoginPanel(ok = { _, _ -> }, error = {})
// }
//}
@@ -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
@@ -52,23 +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.UNNApp
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.composes.About
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()
@@ -175,8 +200,7 @@ class MainActivity : ComponentActivity() {
Box(Modifier.padding(innerPadding)) {
NavHost(navController, startDestination = "journal/schedule") {
composable("portal/blogposts") {
Text("Not implemented")
// Blogposts()
Blogposts()
}
composable("journal/schedule") {
Schedule()
@@ -193,6 +217,44 @@ class MainActivity : ComponentActivity() {
}
}
override fun onStart() {
super.onStart()
connectivityManager.registerDefaultNetworkCallback(networkCallback)
}
override fun onStop() {
connectivityManager.unregisterNetworkCallback(networkCallback)
super.onStop()
}
private fun restoreSavedSession() {
val container = (application as UNNApp).container
if (
container.sessionManager.sessionId != null ||
restoreSessionJob?.isActive == true
) {
return
}
restoreSessionJob = lifecycleScope.launch {
if (container.authRepo.restoreSession() == LoginResult.InvalidCredentials) {
openLogin()
}
}
}
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
@@ -287,4 +349,4 @@ class MainActivity : ComponentActivity() {
Text("User not found")
}
}
}
}
@@ -1,15 +1,19 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn
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.db.AppDatabase
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
@@ -31,8 +35,9 @@ class UNNApp : Application() {
class AppContainer(context: Context) {
val credentialsStore = CredentialsStore(context)
val sessionManager = SessionManager()
val sessionRefreshHandler = SessionRefreshHandler()
val httpClient = createHttpClient()
val httpClient = createHttpClient(sessionRefreshHandler)
val portalApi = PortalApi(
client = httpClient,
@@ -41,13 +46,28 @@ class AppContainer(context: Context) {
val authRepo = AuthRepository(credentialsStore, portalApi)
init {
sessionRefreshHandler.setRefreshAction {
authRepo.restoreSession() == LoginResult.Success
}
}
val database = roomDb<AppDatabase>(name = "cache") {
fallbackToDestructiveMigration(dropAllTables = true)
}
val userRepository = UserRepository(
portalApi,
database,
)
val newsRepository = NewsRepository(
portalApi,
database,
sessionManager,
userRepository
)
val scheduleRepository = ScheduleRepository(
api = portalApi,
db = database,
session = sessionManager
)
}
}
@@ -2,8 +2,10 @@
package ru.sweetbread.unn.core
import android.util.Log
import kotlinx.io.IOException
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
@@ -11,6 +13,10 @@ class AuthRepository(
private val credentialsStore: CredentialsStore,
private val api: PortalApi
) {
private val restoreMutex = Mutex()
private val _authenticationFailures = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val authenticationFailures = _authenticationFailures.asSharedFlow()
suspend fun login(
login: String,
password: String
@@ -26,21 +32,22 @@ class AuthRepository(
return res
}
suspend fun restoreSession(): Boolean {
val credentials =
credentialsStore.get()
?: return false
fun hasSavedCredentials(): Boolean = credentialsStore.get() != null
return try {
val res = api.login(
credentials.login,
credentials.password
)
suspend fun restoreSession(): LoginResult? = restoreMutex.withLock {
val credentials = credentialsStore.get() ?: return@withLock null
res != LoginResult.InvalidCredentials
} catch (e: IOException) {
Log.e("AuthRepo", "restoreSession error", e)
false
val result = api.login(
credentials.login,
credentials.password
)
if (result == LoginResult.InvalidCredentials) {
credentialsStore.clear()
api.session.clear()
_authenticationFailures.emit(Unit)
}
result
}
}
}
@@ -7,6 +7,7 @@ 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
@@ -97,48 +98,55 @@ class CredentialsStore(
}
fun get(): Credentials? {
val encryptedString =
prefs.getString("data", null)
?: return null
return try {
val encryptedString =
prefs.getString("data", null)
?: return null
val ivString =
prefs.getString("iv", null)
?: return null
val ivString =
prefs.getString("iv", null)
?: return null
val encrypted = Base64.decode(
encryptedString,
Base64.NO_WRAP
)
val encrypted = Base64.decode(
encryptedString,
Base64.NO_WRAP
)
val iv = Base64.decode(
ivString,
Base64.NO_WRAP
)
val iv = Base64.decode(
ivString,
Base64.NO_WRAP
)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
key,
GCMParameterSpec(128, iv)
)
cipher.init(
Cipher.DECRYPT_MODE,
key,
GCMParameterSpec(128, iv)
)
val plainText = cipher
.doFinal(encrypted)
.decodeToString()
val plainText = cipher
.doFinal(encrypted)
.decodeToString()
val parts = plainText.split('\u0000', limit = 2)
val parts = plainText.split('\u0000', limit = 2)
if (parts.size != 2)
return null
return Credentials(
login = parts[0],
password = parts[1]
)
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() }
}
}
}
@@ -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
}
@@ -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<AuditoriumDB>)
@Delete
fun delete(auditorium: AuditoriumDB)
}
@@ -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<BuildingDB>)
@Delete
fun delete(building: BuildingDB)
}
@@ -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<DisciplineDB>)
@Delete
fun delete(discipline: DisciplineDB)
}
@@ -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<KindOfWorkDB>)
@Delete
fun delete(kindOfWork: KindOfWorkDB)
}
@@ -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<LecturerDB>)
@Delete
fun delete(lecturer: LecturerDB)
}
@@ -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<List<NewsDB>>
@Query("SELECT * FROM newsDB ORDER BY `publishedAt` DESC")
fun paging(): PagingSource<Int, NewsDB>
@Query("DELETE FROM newsDB")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(news: List<NewsDB>)
@Delete
suspend fun delete(unit: NewsDB)
}
@@ -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<ScheduleUnitDB>
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
fun getScheduleFlow(date: String): Flow<List<ScheduleUnitDB>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(units: List<ScheduleUnitDB>)
@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<List<ScheduleUnitDB>>
@Query("SELECT * FROM scheduleUnitDB")
suspend fun getAll(): List<ScheduleUnitDB>
}
@@ -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)
}
@@ -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<Auditorium>) {
db.withTransaction {
db.buildingDao().insert(
auditoriums.map { it.building }.map { it.toEntity() }
)
db.auditoriumDao().insert(
auditoriums.map { it.toEntity() }
)
}
}
@@ -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))
@@ -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))
@@ -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)
)
@@ -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)
)
@@ -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
)
@@ -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<ScheduleUnit>) {
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() })
}
}
@@ -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
}
@@ -11,9 +11,9 @@ class PortalApi (
val client: HttpClient,
val session: SessionManager
) {
public val auth = Auth(client, session)
public val auth = Auth(client)
public val ruzapi = Ruzapi(client, session)
public val bitrix = Bitrix(client, session)
public val bitrix = Bitrix(client)
/**
* Authorize user by [login] and [password]
@@ -29,7 +29,7 @@ class PortalApi (
LoginResult.InvalidCredentials
} else {
session.update(
user = bitrix.getCurrentProfile(),
user = bitrix.getCurrentUser(),
sessionId = id,
uns = login.drop(1).toInt()
)
@@ -40,60 +40,6 @@ class PortalApi (
LoginResult.NetworkError
}
}
// /**
// * Save info about current [User] in memory
// */
// private suspend fun getMyself(login: String): User = coroutineScope {
// val idDeferred = async { getId(login) }
// val userDeferred = async { getUser() }
//
// val id = idDeferred.await()
// val user = userDeferred.await()
//
// return@coroutineScope User(
// bx_id = null,
// ra_id = null,
// type = Type.student,
// email = user.email,
// nameRu = user.nameRu,
// nameEn = user.nameEn,
// isMale = user.isMale,
// birthday = user.birthday,
// avatar = user.avatar
// )
// }
// suspend fun getScheduleDay(
// date: LocalDate,
// type: Type = session.user.value!!.type,
// id: Int = ME.unnId!!
// ): List<ScheduleUnit> {
// if ((type == ME.type) and (id == ME.unnId!!)) {
// val schedule = withContext(Dispatchers.IO) { loadSchedule(date) }
// Log.d("Schedule", schedule.joinToString())
// if (schedule.isNotEmpty())
// return schedule
// }
//
// return getSchedule(type, id, date, date)
// }
//
// suspend fun getSchedule(
// type: Type = ME.type,
// id: Int = ME.unnId!!,
// start: LocalDate,
// finish: LocalDate
// ): ArrayList<ScheduleUnit> {
// val schedule = downloadSchedule(type, id, start, finish)
//
// if ((type == ME.type) and (id == ME.unnId!!))
// cacheSchedule(schedule)
//
// return schedule
// }
}
sealed interface LoginResult {
@@ -6,14 +6,37 @@ 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
fun createHttpClient() = HttpClient(Android) {
private val SkipSessionRefresh = AttributeKey<Unit>("SkipSessionRefresh")
private val SessionRefreshAttempted = AttributeKey<Unit>("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) {
@@ -34,4 +57,24 @@ fun createHttpClient() = HttpClient(Android) {
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
}
}
}
@@ -11,14 +11,15 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.parameters
import org.json.JSONArray
import org.json.JSONObject
import ru.sweetbread.unn.api.Auditorium
import ru.sweetbread.unn.api.Building
import ru.sweetbread.unn.api.Discipline
import ru.sweetbread.unn.api.KindOfWork
import ru.sweetbread.unn.api.Lecturer
import ru.sweetbread.unn.api.LecturerRank
import ru.sweetbread.unn.api.ScheduleUnit
import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.model.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
@@ -36,7 +37,6 @@ const val restURL = "$portalURL/rest"
class Auth (
val client: HttpClient,
val session: SessionManager
) {
/**
* Get token (phpsessid header) from portal
@@ -50,7 +50,10 @@ class Auth (
append("USER_LOGIN", login)
append("USER_PASSWORD", password)
}
) { retry { noRetry() } }
) {
retry { noRetry() }
skipSessionRefresh()
}
if (r.status.value == 302)
return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
@@ -67,9 +70,10 @@ class Auth (
/**
* Bitrix addon for universities
*
* Bitrix addon for universities.
* Here are schedules, students and lecturers, auditorium and building
*
* РУЗ - расписание учебных занятий
*/
class Ruzapi(
val client: HttpClient,
@@ -179,183 +183,24 @@ class Ruzapi(
class Bitrix(
val client: HttpClient,
val session: SessionManager
) {
suspend fun getCurrentProfile(): User {
val r = client.get("$vuzapiURL/profile/current")
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 getId(login: String): Int {
// return JSONObject(client.get("$ruzapiURL/studentinfo/") {
// header("Cookie", "PHPSESSID=${PHPSESSID}")
// parameter("uns", login.drop(1))
// }.bodyAsText()).getString("id").toInt()
//}
//
//suspend fun getUser(userId: Int? = null): User {
// // WARNING: trailing / is important, 'cuz API devs are eating shit
// // TODO: make up another way to get unnId: this is not useful for lectures
// val json = JSONObject(
// client.get("$vuzapiURL/user/${userId ?: ""}") {
// header("Cookie", "PHPSESSID=$PHPSESSID")
// }.bodyAsText()
// )
//
// return User(
// unnId = null,
// userId = json.getInt("id"),
// type = if (json.getJSONArray("profiles").getJSONObject(0)
// .getString("type") == "employee"
// ) Type.employee else Type.student,
// email = json.getString("email"),
// nameRu = json.getString("fullname"),
// nameEn = json.getString("fullname_en"),
// isMale = json.getString("sex") == "M",
// birthday = Instant
// .parse(json.getString("birthdate"))
// .atZone(ZoneId.of("Europe/Moscow"))
// .toLocalDate(),
// avatar = json.getJSONObject("photo").let {
// AvatarSet(
// it.getString("orig"),
// it.getString("thumbnail"),
// it.getString("small"),
// )
// }
// )
//}
//
//suspend fun getProfile
//
///**
// * Fetch schedule from Uni API
// *
// * @return [ArrayList] of all [ru.sweetbread.unn.api.ScheduleUnit]s between [start] and [finish]
// */
//suspend fun downloadSchedule(
// type: Type = ME.type,
// id: Int = ME.unnId!!,
// start: LocalDate,
// finish: LocalDate
//): ArrayList<ScheduleUnit> {
// val unnDatePattern = DateTimeFormatter.ofPattern("yyyy-MM-dd")
//
// val r = client.get("$ruzapiURL/schedule/${type.name}/$id") {
// parameter("start", start.format(unnDatePattern))
// parameter("finish", finish.format(unnDatePattern))
// parameter("lng", "1")
// }
//
// val json = JSONArray(r.bodyAsText())
// val out = arrayListOf<ScheduleUnit>()
// for (i in 0 until json.length()) {
// val unit = json.getJSONObject(i)
// val lecturesJson = unit.getJSONArray("listOfLecturers")
// val lecturers = arrayListOf<Lecturer>()
//
// for (j in 0 until lecturesJson.length()) {
// val lecturer = lecturesJson.getJSONObject(j)
// lecturers.add(
// Lecturer(
// name = lecturer.getString("lecturer"),
// email = lecturer.getString("lecturerEmail"),
// unnId = lecturer.getInt("lecturerOid"),
// uid = lecturer.getString("lecturerUID"),
// rank = when (lecturer.getString("lecturer_rank")) {
// "АССИСТ" -> LecturerRank.Assistant
// "СТПРЕП" -> LecturerRank.SLecturer
// "ДОЦЕНТ" -> LecturerRank.AProfessor
// else -> LecturerRank.Lecturer
// }
// )
// )
// }
//
// out.add(
// ScheduleUnit(
// oid = unit.getInt("lessonOid"),
// auditorium = Auditorium(
// name = unit.getString("auditorium"),
// oid = unit.getInt("auditoriumOid"),
// floor = unit.getInt("auditoriumfloor"),
// building = Building(
// name = unit.getString("building"),
// gid = unit.getInt("buildingGid"),
// oid = unit.getInt("buildingOid")
// )
// ),
// date = LocalDate.parse(unit.getString("date"), unnDatePattern),
// discipline = Discipline(
// name = unit.getString("discipline"),
// oid = unit.getInt("disciplineOid"),
// type = unit.getInt("disciplinetypeload")
// ),
// kindOfWork = KindOfWork(
// name = unit.getString("kindOfWork"),
// complexity = unit.getInt("kindOfWorkComplexity"),
// oid = unit.getInt("kindOfWorkOid"),
// uid = unit.getString("kindOfWorkUid")
// ),
// lecturers = lecturers,
// stream = unit.getString("stream"),
// begin = LocalTime.parse(
// unit.getString("beginLesson"),
// DateTimeFormatter.ofPattern("HH:mm")
// ),
// end = LocalTime.parse(
// unit.getString("endLesson"),
// DateTimeFormatter.ofPattern("HH:mm")
// )
// )
// )
// }
//
// return out
//}
//
//
//suspend fun getUserByBitrixId(id: Int): User {
// withContext(Dispatchers.IO) {
// loadUserByBitrixId(id)
// }?.let { return it }
//
// val userId = JSONObject(client.get("$vuzapiURL/user/bx/$id") {
// header("Cookie", "PHPSESSID=${PHPSESSID}")
// }.bodyAsText()).getInt("id")
//
// getUser(userId).let { user ->
// withContext(Dispatchers.IO) {
// cacheUser(user)
// }
// return user
// }
//}
//
//suspend fun getBlogposts(): ArrayList<Post> {
// val r = client.get("$prtl2URL/news.php") {
// header("Cookie", "PHPSESSID=${PHPSESSID}")
// header("x-bitrix-sessid-token", CSRF)
// }
// val result = JSONArray(r.bodyAsText())
//
// val out = arrayListOf<Post>()
// for (i in 0 until result.length()) {
// val el = result.getJSONObject(i)
// out.add(
// Post(
// id = el.getString("id").toInt(),
// authorId = el.getJSONObject("author").getInt("id").toInt(),
// enableComments = true, // FIXME: Delete the field or get correct value
// numComments = el.getString("commentsnum").toInt(),
// date = LocalDateTime.parse(
// el.getString("time"),
// DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss")
// ),
// content = el.getString("fulltext")
// )
// )
// }
// return out
//}
suspend fun getNews(page: Int = 1, amount: Int = 20): ArrayList<NewsDto> {
val r = client.get("$prtl2URL/news_.php") {
parameter("numpage", page)
parameter("perpage", amount)
}
return NewsDto.fromJsonArray(JSONArray(r.bodyAsText()))
}
}
@@ -1,24 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.db
import androidx.room.Database
import androidx.room.RoomDatabase
@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
}
@@ -1,269 +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 androidx.room.withTransaction
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")
suspend fun get(oid: Int): BuildingDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(buildings: List<BuildingDB>)
@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")
suspend fun get(oid: Int): AuditoriumDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(auditoriums: List<AuditoriumDB>)
@Delete
fun delete(auditorium: AuditoriumDB)
}
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<Auditorium>) {
db.withTransaction {
db.buildingDao().insert(
auditoriums.map { it.building }.map { it.toEntity() }
)
db.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")
suspend fun get(oid: Int): DisciplineDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(disciplines: List<DisciplineDB>)
@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")
suspend fun get(oid: Int): KindOfWorkDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(kindOfWork: List<KindOfWorkDB>)
@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")
suspend fun get(unnId: Int): LecturerDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(lecturers: List<LecturerDB>)
@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")
suspend fun getSchedule(oid: Int): ScheduleUnitDB?
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
suspend fun getSchedule(date: String): List<ScheduleUnitDB>
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
fun getScheduleFlow(date: String): Flow<List<ScheduleUnitDB>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(units: List<ScheduleUnitDB>)
@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<List<ScheduleUnitDB>>
@Query("SELECT * FROM scheduleUnitDB")
suspend fun getAll(): List<ScheduleUnitDB>
}
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<ScheduleUnit>) {
db.withTransaction {
cacheAuditorium(db, units.map { it.auditorium })
db.disciplineDao().insert(units.map { it.discipline.toEntity() })
db.kindOfWorkDao().insert(units.map { it.kindOfWork.toEntity() })
db.lecturerDao().insert(units.flatMap { it.lecturers }.map { it.toEntity() })
db.scheduleDao().insert(units.map { it.toEntity() })
}
}
//fun loadSchedule(db: AppDatabase, date: LocalDate): List<ScheduleUnit> =
// db.scheduleDao().getSchedule(date.format(DateTimeFormatter.ISO_DATE))
// .mapNotNull { it.toModel(db) }
@@ -1,93 +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.PrimaryKey
import androidx.room.Query
import ru.sweetbread.unn.model.Type
@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
// )
// )
// }
//}
@@ -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
@@ -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<Int, Int> {
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
}
@@ -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<Int, NewsDB>() {
private var nextPage = 1
@RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
@OptIn(ExperimentalPagingApi::class)
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, NewsDB>
): 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)
}
}
}
@@ -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<PagingData<News>> = 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)
}
}
}
@@ -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
)
}
}
}
}
@@ -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()
}
}
@@ -44,15 +44,15 @@ import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
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.ui.composes.BoundedWeekPicker
import ru.sweetbread.unn.ui.composes.GroupsNameText
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
@@ -5,12 +5,11 @@ package ru.sweetbread.unn.feature.schedule
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.sweetbread.unn.api.ScheduleUnit
import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.core.db.AppDatabase
import ru.sweetbread.unn.core.db.entity.cacheSchedule
import ru.sweetbread.unn.core.db.entity.toModel
import ru.sweetbread.unn.core.network.PortalApi
import ru.sweetbread.unn.db.AppDatabase
import ru.sweetbread.unn.db.cacheSchedule
import ru.sweetbread.unn.db.toModel
import ru.sweetbread.unn.model.ScheduleUnit
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -18,7 +17,6 @@ import java.time.format.DateTimeFormatter
class ScheduleRepository (
val api: PortalApi,
val db: AppDatabase,
val session: SessionManager
) {
fun observeSchedule(date: LocalDate): Flow<List<ScheduleUnit>> =
db.scheduleDao()
@@ -44,4 +42,4 @@ class ScheduleRepository (
}
}
}
}
}
@@ -14,8 +14,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import ru.sweetbread.unn.UNNApp
import ru.sweetbread.unn.api.ScheduleUnit
import ru.sweetbread.unn.app.UNNApp
import ru.sweetbread.unn.model.ScheduleUnit
import java.time.LocalDate
data class ScheduleUiState(
@@ -88,4 +88,4 @@ class ScheduleViewModel(
}
}
}
}
}
@@ -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) }
}
}
}
@@ -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
@@ -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<NewsComment>,
// 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<NewsDto> {
val out: ArrayList<NewsDto> = 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<NewsComment> {
//
// }
// }
//}
@@ -1,19 +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 java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
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,
@@ -27,6 +20,13 @@ class ScheduleUnit(
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,
@@ -59,19 +59,4 @@ class Lecturer(
val email: String,
val unnId: Int,
val uid: String
)
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
)
@@ -4,49 +4,38 @@ package ru.sweetbread.unn.model
import org.json.JSONObject
import ru.sweetbread.unn.R
import ru.sweetbread.unn.api.AvatarSet
import splitties.resources.appStr
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
enum class Type(val s: String) {
student(appStr(R.string.student)),
group("group"),
lecturer(appStr(R.string.lecturer)),
auditorium("auditorium"),
employee(appStr(R.string.employee))
Student(appStr(R.string.student)),
Lecturer(appStr(R.string.lecturer)),
Employee(appStr(R.string.employee))
}
class User(
// val unnId: Int?,
// val userId: Int,
val bx_id: Int?, // Bitrix user ID
val ra_id: Int?, // ruzapi ID (for schedule)
val type: Type,
val email: String,
val nameRu: String,
val nameEn: String,
val isMale: Boolean,
val birthday: LocalDate,
val avatar: AvatarSet
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("bitrix_id"),
null,
when (json.getString("type")) {
"student" -> Type.student
else -> Type.employee
constructor(json: JSONObject) : this(
json.getInt("id"),
when (
json.getJSONArray("profiles")
.getJSONObject(0)
.getString("type")
) {
"student" -> Type.Student
else -> Type.Employee
},
json.getJSONObject("user").getString("email"),
json.getJSONObject("user").getString("fullname"),
json.getJSONObject("user").getString("fullname_en"),
json.getJSONObject("user").getString("sex") == "M",
Instant
.parse(json.getJSONObject("user").getString("birthdate"))
.atZone(ZoneId.of("Europe/Moscow"))
.toLocalDate(),
json.getJSONObject("user").getJSONObject("photo").let {
json.getString("email"),
json.getString("fullname"),
json.getString("fullname_en"),
json.getJSONObject("photo").let {
AvatarSet(
it.getString("orig"),
it.getString("thumbnail"),
@@ -54,4 +43,71 @@ class User(
)
}
)
}
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
)
@@ -1,342 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes
//import android.graphics.drawable.Drawable
//import android.text.Html
//import android.text.method.LinkMovementMethod
//import android.text.util.Linkify
//import android.util.Log
//import android.widget.TextView
//import androidx.compose.foundation.background
//import androidx.compose.foundation.clickable
//import androidx.compose.foundation.layout.Arrangement
//import androidx.compose.foundation.layout.Column
//import androidx.compose.foundation.layout.Row
//import androidx.compose.foundation.layout.fillMaxWidth
//import androidx.compose.foundation.layout.padding
//import androidx.compose.foundation.layout.size
//import androidx.compose.foundation.layout.width
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material3.HorizontalDivider
//import androidx.compose.material3.LinearProgressIndicator
//import androidx.compose.material3.MaterialTheme
//import androidx.compose.material3.Surface
//import androidx.compose.material3.Text
//import androidx.compose.runtime.Composable
//import androidx.compose.runtime.LaunchedEffect
//import androidx.compose.runtime.NonRestartableComposable
//import androidx.compose.runtime.collectAsState
//import androidx.compose.runtime.getValue
//import androidx.compose.runtime.mutableIntStateOf
//import androidx.compose.runtime.mutableStateOf
//import androidx.compose.runtime.remember
//import androidx.compose.runtime.setValue
//import androidx.compose.ui.Alignment
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.draw.clip
//import androidx.compose.ui.graphics.toArgb
//import androidx.compose.ui.res.stringResource
//import androidx.compose.ui.text.font.FontStyle
//import androidx.compose.ui.text.font.FontWeight
//import androidx.compose.ui.tooling.preview.Preview
//import androidx.compose.ui.unit.dp
//import androidx.compose.ui.viewinterop.AndroidView
//import androidx.lifecycle.ViewModel
//import androidx.lifecycle.viewmodel.compose.viewModel
//import coil.ImageLoader
//import coil.compose.AsyncImage
//import coil.request.ImageRequest
//import kotlinx.coroutines.flow.MutableStateFlow
//import kotlinx.coroutines.flow.StateFlow
//import kotlinx.coroutines.flow.asStateFlow
//import ru.sweetbread.unn.R
//import ru.sweetbread.unn.api.AvatarSet
//import ru.sweetbread.unn.api.Post
//import ru.sweetbread.unn.api.Type
//import ru.sweetbread.unn.api.User
//import ru.sweetbread.unn.core.network.getBlogposts
//import ru.sweetbread.unn.core.network.getUserByBitrixId
//import ru.sweetbread.unn.core.network.portalURL
//import ru.sweetbread.unn.ui.theme.UNNTheme
//import java.time.LocalDate
//import java.time.LocalDateTime
//import java.time.format.DateTimeFormatter
//import java.time.format.FormatStyle
//
//
//val defUser = User(
// null,
// 123,
// Type.student,
// "cool.email@domain.com",
// "Джон Сигма Омегович",
// "Jon Sigma Omega",
// true,
// LocalDate.now(),
// AvatarSet(
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg"
// )
//)
//
//
//@Composable
//fun Blogposts(viewModel: PostViewModel = viewModel()) {
// val posts by viewModel.posts.collectAsState()
// var extended by remember { mutableIntStateOf(0) }
//
// LaunchedEffect(Unit) {
// viewModel.loadPosts()
// }
//
// if (posts.isNotEmpty()) {
// Log.d("Another fuck", posts.size.toString())
// LazyColumn {
// items(posts) {
// PostItem(
// Modifier
// .padding(8.dp)
// .clip(RoundedCornerShape(16.dp))
// .background(MaterialTheme.colorScheme.secondaryContainer)
// .clickable {
// extended = if (extended == it.id) 0 else it.id
// },
// post = it,
// extended == it.id
// )
// }
// }
// } else {
// LinearProgressIndicator(
// modifier = Modifier
// .fillMaxWidth()
// .padding(8.dp),
// color = MaterialTheme.colorScheme.surfaceVariant,
// trackColor = MaterialTheme.colorScheme.secondary,
// )
// }
//}
//
//
//class PostRepository {
// suspend fun loadPosts(): List<Post> {
// return getBlogposts()
// }
//}
//
//class PostViewModel : ViewModel() {
// private val repository = PostRepository()
// private val _posts = MutableStateFlow<List<Post>>(emptyList())
// val posts: StateFlow<List<Post>> = _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<Int, Int> {
// 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
//}
+3 -10
View File
@@ -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)
}
}
+1 -29
View File
@@ -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
+35 -40
View File
@@ -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" }
+3 -2
View File
@@ -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
+4 -1
View File
@@ -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 {