Compare commits

1 Commits
Author SHA1 Message Date
sweetbread a85c9593e2 Init commit 2024-03-19 00:27:57 +03:00
102 changed files with 962 additions and 3544 deletions
+6 -2
View File
@@ -1,11 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
secrets.properties
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+123
View File
@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="GreetingPreview">
<State />
</entry>
<entry key="GreetingPreview2">
<State />
</entry>
<entry key="LoginActivity">
<State />
</entry>
<entry key="app">
<State />
</entry>
</value>
</component>
</project>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" />
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
-15
View File
@@ -1,15 +0,0 @@
<UNN HUB - unofficial app providing schedule, news and document access for students>
Copyright (C) 2025 Gleb Zaharov
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-70
View File
@@ -1,70 +0,0 @@
# NOTICE
This project incorporates components from the following third-party libraries:
## AndroidX Libraries
- **androidx.*** (Core, Room, Navigation, Compose, etc.)
Copyright The Android Open Source Project
Licensed under Apache License 2.0
[https://developer.android.com](https://developer.android.com)
## Coil
- **io.coil-kt:coil-compose**
Copyright Coil Contributors
Licensed under Apache License 2.0
[https://github.com/coil-kt/coil](https://github.com/coil-kt/coil)
## Compose Calendar
- **com.kizitonwose.calendar:compose**
Copyright Kizito Nwose
Licensed under MIT License
[https://github.com/kizitonwose/Calendar](https://github.com/kizitonwose/Calendar)
## Desugar JDK Libs
- **com.android.tools:desugar_jdk_libs**
Copyright Google LLC
Licensed under Apache License 2.0
## Ktor
- **io.ktor:ktor-***
Copyright JetBrains s.r.o.
Licensed under Apache License 2.0
[https://ktor.io](https://ktor.io)
## Material Components
- **com.google.android.material:material**
Copyright Google LLC
Licensed under Apache License 2.0
## Splitties
- **com.louiscad.splitties:***
Copyright Louis CAD
Licensed under Apache License 2.0
[https://github.com/LouisCAD/Splitties](https://github.com/LouisCAD/Splitties)
## Secrets Gradle Plugin
- **com.google.android.libraries.mapsplatform.secrets-gradle-plugin**
Copyright Google LLC
Licensed under Apache License 2.0
## Sentry Android
- **io.sentry:sentry-android**
Copyright Sentry and contributors
Licensed under MIT License
[https://github.com/getsentry/sentry-java](https://github.com/getsentry/sentry-java)
## Build Tools
- **Gradle Plugins** (Android, Kotlin)
Copyright Google LLC / JetBrains s.r.o.
Licensed under Apache License 2.0
---
### License Notices
This product includes software developed by:
- The Android Open Source Project (AOSP)
- JetBrains s.r.o.
- Google LLC
- And other contributors listed above
Full license texts are available in the [LICENSE](LICENSE) file and respective library repositories.
+24 -70
View File
@@ -1,25 +1,16 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.sentry.android)
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
}
android {
namespace = "ru.sweetbread.unn"
compileSdk = 37
compileSdk = 34
defaultConfig {
applicationId = "ru.sweetbread.unn"
minSdk = 26
targetSdk = 36
targetSdk = 34
versionCode = 1
versionName = "1.0"
@@ -27,53 +18,27 @@ android {
vectorDrawables {
useSupportLibrary = true
}
val secretProperties = Properties().apply {
val secretFile = rootProject.file("secrets.properties")
if (secretFile.exists())
secretFile.inputStream().use { load(it) }
else
println("Warning: secrets.properties not found!")
}
manifestPlaceholders["sentry_url"] = secretProperties.getProperty("SENTRY_URL")!!
// javaCompileOptions {
// annotationProcessorOptions {
// arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
// }
// }
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
resValue("string", "app_name", "@string/app_name_reg")
manifestPlaceholders["sentry_env"] = "production"
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix =
"-debug+${LocalDateTime.now().format(DateTimeFormatter.BASIC_ISO_DATE)}"
resValue("string", "app_name", "@string/app_name_dev")
manifestPlaceholders["sentry_env"] = "debug"
}
create("beta") {
versionNameSuffix =
"-beta+${LocalDateTime.now().format(DateTimeFormatter.BASIC_ISO_DATE)}"
resValue("string", "app_name", "@string/app_name_beta")
signingConfig = signingConfigs.getByName("debug")
manifestPlaceholders["sentry_env"] = "beta"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
resValues = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
@@ -82,20 +47,10 @@ android {
}
}
base {
archivesName = "ru.sweetbread.unn-v${android.defaultConfig.versionCode}(${android.defaultConfig.versionName})"
}
dependencies {
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)
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.5.0")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@@ -103,8 +58,12 @@ 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)
@@ -112,21 +71,16 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
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)
implementation(libs.splitties.room)
implementation("com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl:3.0.0")
implementation(libs.sentry)
implementation("com.kizitonwose.calendar:compose:2.5.0")
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
}
}
-37
View File
@@ -1,37 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "ru.sweetbread.unn",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "ru.sweetbread.unn-v1(1.0)-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/ru.sweetbread.unn-v1(1.0)-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/ru.sweetbread.unn-v1(1.0)-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}
Binary file not shown.
@@ -1,13 +1,13 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
+7 -40
View File
@@ -1,56 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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=".app.UNNApp"
android:allowBackup="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.UNN"
tools:targetApi="31">
<meta-data
android:name="io.sentry.dsn"
android:value="${sentry_url}" />
<meta-data
android:name="io.sentry.environment"
android:value="${sentry_env}" />
<meta-data
android:name="io.sentry.send-default-pii"
android:value="true" />
<meta-data
android:name="io.sentry.traces.sample-rate"
android:value="1.0" />
<meta-data
android:name="io.sentry.traces.user-interaction.enable"
android:value="true" />
<meta-data
android:name="io.sentry.attach-screenshot"
android:value="true" />
<meta-data
android:name="io.sentry.attach-view-hierarchy"
android:value="true" />
<meta-data
android:name="io.sentry.session-replay.on-error-sample-rate"
android:value="1.0" />
<meta-data
android:name="io.sentry.session-replay.session-sample-rate"
android:value="0.1" />
<activity
android:name=".app.MainActivity"
android:exported="true"
android:theme="@style/Theme.UNN" />
<activity
android:name=".app.LoginActivity"
android:name=".ui.layout.MainActivity"
android:exported="true"
android:theme="@style/Theme.UNN">
<intent-filter>
@@ -59,6 +22,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.layout.LoginActivity"
android:exported="true"
android:theme="@style/Theme.UNN"/>
</application>
</manifest>
</manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 48 KiB

@@ -1,352 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
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
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
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 coil3.compose.AsyncImage
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import ru.sweetbread.unn.R
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?) {
super.onCreate(savedInstanceState)
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
) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
DrawerContent(
Modifier
.fillMaxHeight()
.fillMaxWidth(.75f)
.background(MaterialTheme.colorScheme.surfaceContainer)
.systemBarsPadding(),
navController = navController
)
}
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
when {
currentRoute?.startsWith("portal/") == true ->
appStr(R.string.news)
currentRoute?.startsWith("journal/") == true ->
appStr(R.string.schedule)
else -> appStr(R.string.app_name)
},
Modifier.padding()
)
},
navigationIcon = {
IconButton(
onClick = {
scope.launch { drawerState.open() }
}
) {
Icon(Icons.Filled.Menu, "Меню")
}
}
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
onClick = {
navController.navigate("portal/blogposts") {
launchSingleTop = true
}
},
icon = {
Icon(
Icons.Filled.Home,
contentDescription = "Home"
)
},
selected = currentRoute?.startsWith("portal/") == true
)
NavigationBarItem(
onClick = {
navController.navigate("journal/schedule") {
launchSingleTop = true
}
},
icon = {
Icon(
Icons.Filled.DateRange,
contentDescription = "Schedule"
)
},
selected = currentRoute?.startsWith("journal/") == true
)
NavigationBarItem(
onClick = { toast("Not implemented") },
icon = {
Icon(
Icons.Filled.AccountBox,
contentDescription = "Account"
)
},
selected = false
)
}
}
) { innerPadding ->
Box(Modifier.padding(innerPadding)) {
NavHost(navController, startDestination = "journal/schedule") {
composable("portal/blogposts") {
Blogposts()
}
composable("journal/schedule") {
Schedule()
}
composable("about") {
About()
}
}
}
}
}
}
}
}
}
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
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")
}
}
}
@@ -1,73 +0,0 @@
// 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<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,
)
}
@@ -1,53 +0,0 @@
// 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<Unit>(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
}
}
@@ -1,152 +0,0 @@
// 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() }
}
}
@@ -1,34 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import ru.sweetbread.unn.model.User
class SessionManager {
private val _user = MutableStateFlow<User?>(null)
val user = _user.asStateFlow()
var sessionId: String? = null
private set
var 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
}
}
@@ -1,43 +0,0 @@
// 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
}
@@ -1,22 +0,0 @@
// 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)
}
@@ -1,22 +0,0 @@
// 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)
}
@@ -1,22 +0,0 @@
// 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)
}
@@ -1,22 +0,0 @@
// 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)
}
@@ -1,22 +0,0 @@
// 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)
}
@@ -1,33 +0,0 @@
// 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)
}
@@ -1,38 +0,0 @@
// 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>
}
@@ -1,21 +0,0 @@
// 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)
}
@@ -1,51 +0,0 @@
// 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() }
)
}
}
@@ -1,21 +0,0 @@
// 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))
@@ -1,22 +0,0 @@
// 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))
@@ -1,28 +0,0 @@
// 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)
)
@@ -1,31 +0,0 @@
// 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)
)
@@ -1,34 +0,0 @@
// 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
)
@@ -1,70 +0,0 @@
// 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() })
}
}
@@ -1,66 +0,0 @@
// 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
}
@@ -1,49 +0,0 @@
// 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
}
@@ -1,80 +0,0 @@
// 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<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) {
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
}
}
}
@@ -1,206 +0,0 @@
// 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<ScheduleUnit> {
// TODO: Check for type = student before using
// _______
val r = client.get("$ruzapiURL/schedule/student") {
parameter("uns", uns)
parameter("start", from.format(unnDatePattern))
parameter("finish", until.format(unnDatePattern))
}
return parseSchedule(r.bodyAsText())
}
private fun parseSchedule(body: String): ArrayList<ScheduleUnit> {
val json = JSONArray(body)
val out = arrayListOf<ScheduleUnit>()
for (i in 0 until json.length()) {
val unit = json.getJSONObject(i)
val lecturesJson = unit.getJSONArray("listOfLecturers")
val lecturers = arrayListOf<Lecturer>()
for (j in 0 until lecturesJson.length()) {
val lecturer = lecturesJson.getJSONObject(j)
lecturers.add(
Lecturer(
name = lecturer.getString("lecturer"),
email = lecturer.getString("lecturerEmail"),
unnId = lecturer.getInt("lecturerOid"),
uid = lecturer.getString("lecturerUID"),
rank = when (lecturer.getString("lecturer_rank")) {
"АССИСТ" -> LecturerRank.Assistant
"СТПРЕП" -> LecturerRank.SLecturer
"ДОЦЕНТ" -> LecturerRank.AProfessor
else -> LecturerRank.Lecturer
}
)
)
}
out.add(
ScheduleUnit(
oid = unit.getInt("lessonOid"),
auditorium = Auditorium(
name = unit.getString("auditorium"),
oid = unit.getInt("auditoriumOid"),
floor = unit.getInt("auditoriumfloor"),
building = Building(
name = unit.getString("building"),
gid = unit.getInt("buildingGid"),
oid = unit.getInt("buildingOid")
)
),
date = LocalDate.parse(unit.getString("date"), unnDatePattern),
discipline = Discipline(
name = unit.getString("discipline"),
oid = unit.getInt("disciplineOid"),
type = unit.getInt("disciplinetypeload")
),
kindOfWork = KindOfWork(
name = unit.getString("kindOfWork"),
complexity = unit.getInt("kindOfWorkComplexity"),
oid = unit.getInt("kindOfWorkOid"),
uid = unit.getString("kindOfWorkUid")
),
lecturers = lecturers,
stream = unit.getString("stream"),
begin = LocalTime.parse(
unit.getString("beginLesson"),
DateTimeFormatter.ofPattern("HH:mm")
),
end = LocalTime.parse(
unit.getString("endLesson"),
DateTimeFormatter.ofPattern("HH:mm")
),
LocalDateTime.now()
)
)
}
return out
}
}
class Bitrix(
val client: HttpClient,
) {
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<NewsDto> {
val r = client.get("$prtl2URL/news_.php") {
parameter("numpage", page)
parameter("perpage", amount)
}
return NewsDto.fromJsonArray(JSONArray(r.bodyAsText()))
}
}
@@ -1,38 +0,0 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ru.sweetbread.unn.R
import splitties.resources.appStr
@Composable
fun About() {
Column(Modifier.fillMaxSize()) {
Text(
appStr(R.string.developer),
style = MaterialTheme.typography.headlineMedium
)
Column(
Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp)
)
.padding(16.dp)
) {
Text("meow")
}
}
}
@@ -1,259 +0,0 @@
// 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
}
@@ -1,89 +0,0 @@
// 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)
}
}
}
@@ -1,48 +0,0 @@
// 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)
}
}
}
@@ -1,33 +0,0 @@
// 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
)
}
}
}
}
@@ -1,28 +0,0 @@
// 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()
}
}
@@ -1,433 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.schedule
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Canvas
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.height
import androidx.compose.foundation.layout.padding
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.compose.viewModel
import kotlinx.coroutines.delay
import ru.sweetbread.unn.R
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
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Calendar
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun Schedule() {
val viewModel: ScheduleViewModel = viewModel(
factory = ScheduleViewModel.Factory
)
Column {
BoundedWeekPicker(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
selectedDate = viewModel.uiState.collectAsState().value.date,
onDateSelected = { viewModel.selectDate(it) }
)
ScheduleDay(viewModel = viewModel)
}
}
@Composable
fun ScheduleDay(modifier: Modifier = Modifier, viewModel: ScheduleViewModel) {
var expanded by remember { mutableIntStateOf(0) }
val state by viewModel.uiState.collectAsState()
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)
}
if (state.isRefreshing) {
item { LinearProgressIndicator(Modifier.fillMaxWidth()) }
} else {
if (lessons.isEmpty())
item { Text(appStr(R.string.noData)) }
}
state.error?.let { item { Text(it) } }
}
}
@Composable
fun ScheduleItem(modifier: Modifier = Modifier, unit: ScheduleUnit, expanded: Boolean = false) {
fun getRatio(): Float {
val begin = LocalDateTime.of(unit.date, unit.begin).atZone(ZoneId.of("Europe/Moscow")).toEpochSecond()
val end = LocalDateTime.of(unit.date, unit.end).atZone(ZoneId.of("Europe/Moscow")).toEpochSecond()
val now = LocalDateTime.now().atZone(ZoneId.of("Europe/Moscow")).toEpochSecond()
if (begin > now)
return -1f
if (now > end)
return 1f
return (now - begin) / (end - begin).toFloat()
}
val begin = unit.begin.format(DateTimeFormatter.ofPattern("HH:mm"))
val end = unit.end.format(DateTimeFormatter.ofPattern("HH:mm"))
var ratio by remember(
unit.date,
unit.begin,
unit.end
) {
mutableFloatStateOf(getRatio())
}
LaunchedEffect(
unit.date,
unit.begin,
unit.end
) {
ratio = getRatio()
while (true) {
val now = System.currentTimeMillis()
val calendar = Calendar.getInstance().apply {
timeInMillis = now
}
val seconds = calendar.get(Calendar.SECOND)
val millisUntilNextMinute =
(60 - seconds) * 1000L -
calendar.get(Calendar.MILLISECOND)
delay(millisUntilNextMinute.milliseconds)
ratio = getRatio()
}
}
val backgroundColor by animateColorAsState(
targetValue = when (ratio) {
1f -> MaterialTheme.colorScheme.surfaceContainer
-1f -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.primaryContainer
},
label = "backgroundTransition"
)
Row (
modifier
.fillMaxWidth()
.padding(4.dp)
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.padding(8.dp)
){
Column (Modifier.weight(1f)) {
Column {
Text(
text = unit.discipline.name,
fontWeight = FontWeight.Bold,
modifier = Modifier.zIndex(1f),
maxLines = if (expanded) Int.MAX_VALUE else 1,
overflow = TextOverflow.Ellipsis
)
}
AnimatedVisibility (expanded) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.onBackground
)
}
Column {
Text(
text = unit.kindOfWork.name,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility (expanded) {
GroupsNameText(
unit.stream,
highlightColor = MaterialTheme.colorScheme.inversePrimary
)
}
}
AnimatedVisibility (expanded) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.onBackground
)
}
AnimatedVisibility (!expanded) {
Row(Modifier) {
Text(
text = unit.auditorium.name,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 4.dp)
)
Text(
text = unit.auditorium.building.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
AnimatedVisibility (expanded) {
Column {
Text(
text = "${stringResource(R.string.auditorium)}: ${unit.auditorium.name}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 4.dp)
)
Text(
text = "${stringResource(R.string.building)}: ${unit.auditorium.building.name}",
overflow = TextOverflow.Ellipsis
)
if (unit.auditorium.floor != 0) {
Text(
text = "${stringResource(R.string.floor)}: ${unit.auditorium.floor}",
overflow = TextOverflow.Ellipsis
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.onBackground
)
Column {
Text(text = unit.lecturers[0].name, fontWeight = FontWeight.Bold)
Text(text = stringResource(unit.lecturers[0].rank.id))
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.onBackground
)
Row (Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text(begin.toString(), fontWeight = FontWeight.Bold)
AnimatedVisibility (
(0f <= ratio) and (ratio < 1f),
modifier = Modifier
.weight(1f)
.padding(horizontal = 2.dp)
) {
DividerWithMarker(
positionPercentage = ratio,
color = MaterialTheme.colorScheme.outline,
thickness = 3.dp,
markerSize = 8.dp,
markerColor = MaterialTheme.colorScheme.primary
)
}
Text(end.toString())
}
}
}
}
AnimatedVisibility (!expanded) {
Column {
Text(begin.toString(), fontWeight = FontWeight.Bold)
Text(end.toString())
}
}
}
}
@Composable
fun DividerWithMarker(
modifier: Modifier = Modifier,
positionPercentage: Float,
color: Color = Color.Gray,
thickness: Dp = 1.dp,
markerSize: Dp = 8.dp,
markerColor: Color = Color.Red
) {
Canvas(modifier = modifier.height(thickness)) {
val dividerHeight = thickness.toPx()
val width = size.width
val markerX = width * positionPercentage
drawLine(
color = markerColor,
start = Offset(0f, dividerHeight / 2),
end = Offset(markerX, dividerHeight / 2),
strokeWidth = dividerHeight
)
drawLine(
color = color,
start = Offset(markerX, dividerHeight / 2),
end = Offset(width, dividerHeight / 2),
strokeWidth = dividerHeight / 2
)
drawCircle(
color = markerColor,
radius = markerSize.toPx() / 2,
center = Offset(markerX, dividerHeight / 2)
)
}
}
@Preview
@Composable
fun ScheduleItemPreview() {
val unit = ScheduleUnit(
oid = 1,
Auditorium(
name = "с/з 1(110)",
oid = 3752,
floor = 0,
building = Building(
name = "Корпус 6",
gid = 30,
oid = 155
),
),
date = LocalDate.of(2024, 3, 11),
discipline = Discipline(
name = "Физическая культура и спорт (элективная дисциплина)",
oid = 67895,
type = 0
),
kindOfWork = KindOfWork(
name = "Практика (семинарские занятия)",
oid = 261,
uid = "281474976710661",
complexity = 1
),
lecturers = arrayListOf(
Lecturer(
name = "Фамилия Имя Отчество",
rank = LecturerRank.SLecturer,
email = "",
unnId = 28000,
uid = "51000"
)
),
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),
LocalDateTime.now()
)
UNNTheme {
Surface(color = MaterialTheme.colorScheme.background) {
ScheduleItem(unit = unit)
}
}
}
@Preview
@Composable
fun ScheduleExpandedItemPreview() {
val unit = ScheduleUnit(
oid = 1,
Auditorium(
name = "с/з 1(110)",
oid = 3752,
floor = 0,
building = Building(
name = "Корпус 6",
gid = 30,
oid = 155
),
),
date = LocalDate.of(2024, 3, 11),
discipline = Discipline(
name = "Физическая культура и спорт (элективная дисциплина)",
oid = 67895,
type = 0
),
kindOfWork = KindOfWork(
name = "Практика (семинарские занятия)",
oid = 261,
uid = "281474976710661",
complexity = 1
),
lecturers = arrayListOf(
Lecturer(
name = "Фамилия Имя Отчество",
rank = LecturerRank.SLecturer,
email = "",
unnId = 28000,
uid = "51000"
)
),
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),
LocalDateTime.now()
)
UNNTheme {
Surface(color = MaterialTheme.colorScheme.background) {
ScheduleItem(unit = unit, expanded = true)
}
}
}
@@ -1,45 +0,0 @@
// 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<List<ScheduleUnit>> =
db.scheduleDao()
.observe(date.format(DateTimeFormatter.ISO_DATE))
.map { ent -> ent.mapNotNull { it.toModel(db) } }
suspend fun refresh(date: LocalDate, diffHrs: Long = 24) {
val dateStr = date.format(DateTimeFormatter.ISO_DATE)
val fromDb = db.scheduleDao()
.getSchedule(dateStr)
.mapNotNull { it.toModel(db) }
if (
fromDb.isEmpty() ||
fromDb.minOf { it.lastUpdated } < LocalDateTime.now().minusHours(diffHrs)
) {
val fromApi = api.ruzapi.getScheduleByUns(from = date, until = date)
db.withTransaction {
db.scheduleDao().deleteByDate(dateStr)
cacheSchedule(db, fromApi)
}
}
}
}
@@ -1,91 +0,0 @@
// 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<ScheduleUnit> = emptyList(),
)
class ScheduleViewModel(
private val repository: ScheduleRepository
) : ViewModel() {
companion object {
val Factory = viewModelFactory {
initializer {
val application = checkNotNull(
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
) as UNNApp
ScheduleViewModel(
repository = application.container.scheduleRepository
)
}
}
}
private val date = MutableStateFlow(LocalDate.now())
private val refreshing = MutableStateFlow(false)
private val error = MutableStateFlow<String?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
private val lessons =
date.flatMapLatest(repository::observeSchedule)
val uiState =
combine(
date,
refreshing,
error,
lessons,
) { date, refreshing, error, lessons ->
ScheduleUiState(
date = date,
isRefreshing = refreshing,
error = error,
lessons = lessons
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
ScheduleUiState()
)
fun selectDate(date: LocalDate) {
this.date.value = date
refresh()
}
fun refresh() {
viewModelScope.launch {
refreshing.value = true
error.value = null
try {
repository.refresh(date.value)
} catch (e: Exception) {
error.value = e.message
} finally {
refreshing.value = false
}
}
}
}
@@ -1,59 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.schedule.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun GroupsNameText(
text: String,
modifier: Modifier = Modifier,
highlightColor: Color = MaterialTheme.colorScheme.secondaryContainer,
highlightPadding: PaddingValues = PaddingValues(horizontal = 6.dp, vertical = 2.dp),
highlightShape: Shape = MaterialTheme.shapes.small,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
) {
val suffix = if (text.endsWith("-В-OUP")) "-В-OUP" else null
val mainPart = if (suffix != null) text.dropLast(6) else text
val blocks = if (mainPart.startsWith("Поток_(") && mainPart.endsWith(')')) {
mainPart
.removePrefix("Поток_(")
.dropLast(1)
.split(';')
.map(String::trim)
.filter(String::isNotEmpty)
} else {
mainPart.split('|')
}
FlowRow(
modifier = modifier,
horizontalArrangement = horizontalArrangement,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
blocks.forEach { block ->
Box(
modifier = Modifier
.padding(end = 2.dp)
.background(highlightColor, highlightShape)
.padding(highlightPadding)
) {
Text(text = block)
}
}
suffix?.let { Text(text = it) }
}
}
@@ -1,141 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.schedule.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale
@Composable
fun BoundedWeekPicker(
modifier: Modifier = Modifier,
selectedDate: LocalDate,
onDateSelected: (LocalDate) -> Unit,
baseDate: LocalDate = LocalDate.now()
) {
val rangeStart = remember(baseDate) {
baseDate
.with(DayOfWeek.MONDAY)
.minusWeeks(2)
}
val pageCount = 2 /* prev */ + 1 /* cur */ + 5 /* next */
fun pageIndexForDate(date: LocalDate): Int {
val weekStart = date.with(DayOfWeek.MONDAY)
return ChronoUnit.WEEKS.between(rangeStart, weekStart)
.toInt()
.coerceIn(0, pageCount - 1)
}
val initialPage = remember(selectedDate) { pageIndexForDate(selectedDate) }
val pagerState = rememberPagerState(
initialPage = initialPage,
pageCount = { pageCount }
)
LaunchedEffect(selectedDate) {
val targetPage = pageIndexForDate(selectedDate)
if (targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage)
}
}
HorizontalPager(
state = pagerState,
modifier = modifier,
beyondViewportPageCount = 1,
pageSpacing = 0.dp
) { page ->
val weekStart = rangeStart.plusWeeks(page.toLong())
val days = List(6) { weekStart.plusDays(it.toLong()) }
WeekPage(
days = days,
selectedDate = selectedDate,
onDateSelected = onDateSelected
)
}
}
@Composable
private fun WeekPage(
days: List<LocalDate>,
selectedDate: LocalDate,
onDateSelected: (LocalDate) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
days.forEach { date ->
DayItem(
date = date,
isSelected = date == selectedDate,
onClick = { onDateSelected(date) }
)
}
}
}
@Composable
private fun DayItem(
date: LocalDate,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
val contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
val fontWeight = if (date == LocalDate.now()) FontWeight.ExtraBold else FontWeight.Normal
Box(
modifier = modifier
.size(56.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()),
style = MaterialTheme.typography.labelSmall,
color = contentColor,
fontWeight = fontWeight
)
Text(
text = date.dayOfMonth.toString(),
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
fontWeight = fontWeight
)
}
}
}
@@ -1,60 +0,0 @@
// 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,62 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.model
import ru.sweetbread.unn.R
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
class ScheduleUnit(
val oid: Int,
val auditorium: Auditorium,
val date: LocalDate,
val discipline: Discipline,
val kindOfWork: KindOfWork,
val lecturers: List<Lecturer>,
val stream: String,
val begin: 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,
val floor: Int,
val building: Building
)
class Building(
val name: String,
val gid: Int,
val oid: Int
)
class Discipline(
val name: String,
val oid: Int,
val type: Int
)
class KindOfWork(
val name: String,
val oid: Int,
val uid: String,
val complexity: Int
)
class Lecturer(
val name: String,
val rank: LecturerRank,
val email: String,
val unnId: Int,
val uid: String
)
@@ -1,113 +0,0 @@
// 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
)
@@ -0,0 +1,181 @@
package ru.sweetbread.unn.ui
import android.util.Log
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.ui.layout.LoginData
import ru.sweetbread.unn.ui.layout.client
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
private lateinit var PHPSESSID: String
lateinit var ME: User
const val portalURL = "https://portal.unn.ru"
const val ruzapiURL = "$portalURL/ruzapi"
enum class Type(val s: String) {
Student("student"),
Group("group"),
Lecturer("lecturer"),
Auditorium("auditorium")
}
enum class LecturerRank(val s: String) {
Lecturer("Lecturer"),
SLecturer("Senior Lecturer")
}
class ScheduleUnit(
val auditorium: Auditorium,
val date: LocalDate,
val discipline: Discipline,
val kindOfWork: KindOfWork,
val lecturers: ArrayList<Lecturer>,
val stream: String,
val begin: LocalTime,
val end: LocalTime)
class Auditorium (val name: String,
val oid: Int,
val floor: Int,
val building: Building)
class Building(val name: String,
val gid: Int,
val oid: Int)
class Discipline (val name: String,
val oid: Int,
val type: Int)
class KindOfWork (val name: String,
val oid: Int,
val uid: String,
val complexity: Int)
class Lecturer (val name: String,
val rank: LecturerRank,
val email: String,
val oid: Int,
val uid: String)
class User (val id: String,
val uns: String,
val type: Type,
val email: String,
val name: String,
val info: String)
/**
* Authorize user by [login] and [password]
*
* Also defines local vars [PHPSESSID] and [ME.id]
*/
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 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) {
PHPSESSID = """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
getMyself(login)
return true
}
return false
}
private suspend fun getMyself(login: String) {
val r = client.get("$ruzapiURL/studentinfo") {
parameter("uns", login.substring(1))
}
val json = JSONObject(r.bodyAsText())
ME = User(
id = json.getString("id"),
uns = json.getString("uns"),
type = when(json.getString("type")) {
"lecturer" -> Type.Lecturer // ig,,,
else -> Type.Student
},
email = json.getString("email"),
name = json.getString("fio"),
info = json.getString("info")
)
}
suspend fun getSchedule(type: Type = Type.Student, id: String = ME.id, start: LocalDate, finish: LocalDate): ArrayList<ScheduleUnit> {
val r = client.get("$ruzapiURL/schedule/${type.s}/$id") {
parameter("start", start.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")))
parameter("finish", finish.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")))
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"),
oid = lecturer.getInt("lecturerOid"),
uid = lecturer.getString("lecturerUID"),
rank = when (lecturer.getString("lecturer_rank")) {
"СТПРЕП" -> LecturerRank.SLecturer
else -> LecturerRank.Lecturer
}
)
)
}
out.add(
ScheduleUnit(
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"), DateTimeFormatter.ofPattern("yyyy.MM.dd")),
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
}
@@ -0,0 +1,204 @@
package ru.sweetbread.unn.ui.composes
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.kizitonwose.calendar.compose.WeekCalendar
import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.sweetbread.unn.ui.Auditorium
import ru.sweetbread.unn.ui.Building
import ru.sweetbread.unn.ui.Discipline
import ru.sweetbread.unn.ui.KindOfWork
import ru.sweetbread.unn.ui.Lecturer
import ru.sweetbread.unn.ui.LecturerRank
import ru.sweetbread.unn.ui.ScheduleUnit
import ru.sweetbread.unn.ui.getSchedule
import ru.sweetbread.unn.ui.theme.UNNTheme
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@Composable
fun Schedule() {
val state = rememberWeekCalendarState(
firstDayOfWeek = DayOfWeek.MONDAY
)
Column {
var curDate by remember { mutableStateOf(LocalDate.now()) }
WeekCalendar(
state = state,
dayContent = {
Box(
modifier = Modifier
.padding(vertical = 16.dp)
.aspectRatio(1f) // This is important for square sizing!
.offset(2.dp)
.background(if (it.date == curDate) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer)
.clickable(
onClick = {
curDate = it.date
Log.d("Here bug (olClick)",
curDate.format(DateTimeFormatter.ISO_DATE)
)
}
),
contentAlignment = Alignment.Center,
) {
Text(
text = it.date.dayOfMonth.toString(),
fontWeight = if (it.date == LocalDate.now()) FontWeight.Bold else null
)
}
}
)
ScheduleDay(date = curDate)
}
}
@Composable
fun ScheduleDay(modifier: Modifier = Modifier, date: LocalDate) {
val scope = rememberCoroutineScope()
var loadedDate by remember { mutableStateOf(LocalDate.MIN) }
val lessons = remember { mutableListOf<ScheduleUnit>() }
if (loadedDate == date) {
Log.d("Loaded", "${date.format(DateTimeFormatter.ISO_DATE)} ${lessons.size}")
LazyColumn (modifier) {
items(lessons) {
ScheduleItem(unit = it)
}
}
} else {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
trackColor = MaterialTheme.colorScheme.secondary,
)
LaunchedEffect(date != loadedDate) {
scope.launch(Dispatchers.IO) {
lessons.clear()
lessons.addAll(getSchedule(start = date, finish = date))
loadedDate = date
Log.d("Loading", "${date.format(DateTimeFormatter.ISO_DATE)} ${lessons.size}")
Log.d("Here bug", "${loadedDate.format(DateTimeFormatter.ISO_DATE)} ${date.format(DateTimeFormatter.ISO_DATE)}")
}
}
}
}
@Composable
fun ScheduleItem(modifier: Modifier = Modifier, unit: ScheduleUnit) {
Row (
modifier
.fillMaxWidth()
.padding(4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(8.dp)
){
Column (Modifier.weight(1f)) {
Text(
text = unit.discipline.name,
fontWeight = FontWeight.Bold,
modifier = Modifier.zIndex(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(text = unit.kindOfWork.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row (Modifier) {
Text(text = unit.auditorium.name, fontWeight = FontWeight.Bold, modifier = Modifier.padding(end = 4.dp))
Text(text = unit.auditorium.building.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Column {
val begin = unit.begin.format(DateTimeFormatter.ofPattern("HH:mm"))
val end = unit.end.format(DateTimeFormatter.ofPattern("HH:mm"))
Text(begin.toString(), fontWeight = FontWeight.Bold)
Text(end.toString())
}
}
}
@Preview
@Composable
fun ScheduleItemPreview() {
val unit = ScheduleUnit(
Auditorium(
name = "с/з 1(110)",
oid = 3752,
floor = 0,
building = Building(
name = "Корпус 6",
gid = 30,
oid = 155
),
),
date = LocalDate.of(2024, 3, 11),
discipline = Discipline(
name = "Физическая культура и спорт (элективная дисциплина)",
oid = 67895,
type = 0
),
kindOfWork = KindOfWork(
name = "Практика (семинарские занятия)",
oid = 261,
uid = "281474976710661",
complexity = 1
),
lecturers = arrayListOf(
Lecturer(
name = "Фамилия Имя Отчество",
rank = LecturerRank.SLecturer,
email = "",
oid = 28407,
uid = "51769"
)
),
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)
)
UNNTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colorScheme.background) {
ScheduleItem(unit = unit)
}
}
}
@@ -1,6 +1,4 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.app
package ru.sweetbread.unn.ui.layout
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -9,20 +7,19 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -36,26 +33,17 @@ 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 ru.sweetbread.unn.R
import ru.sweetbread.unn.core.AuthRepository
import ru.sweetbread.unn.ui.auth
import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.activities.start
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val authRepo = (application as UNNApp).container.authRepo
if (authRepo.hasSavedCredentials()) {
start<MainActivity>()
finish()
return
}
setContent {
UNNTheme {
Surface(
@@ -70,27 +58,20 @@ class LoginActivity : ComponentActivity() {
SnackbarHost(hostState = snackbarHostState)
}
) { innerPadding ->
Box(Modifier
.padding(innerPadding)
.fillMaxSize(), Alignment.Center) {
LoginPanel(
Modifier.imePadding(),
authRepo,
{
start<MainActivity>()
finish()
},
{
scope.launch {
snackbarHostState
.showSnackbar(
message = "Error",
duration = SnackbarDuration.Short
)
}
}
)
}
LoginPanel(Modifier.padding(innerPadding), { login, password ->
LoginData.login = login
LoginData.password = password
start<MainActivity>()
}, {
scope.launch {
snackbarHostState
.showSnackbar(
message = "Error",
duration = SnackbarDuration.Long
)
}
})
}
}
}
@@ -101,8 +82,7 @@ class LoginActivity : ComponentActivity() {
@Composable
fun LoginPanel(
modifier: Modifier = Modifier,
authRepo: AuthRepository,
ok: () -> Unit,
ok: (login: String, password: String) -> Unit,
error: () -> Unit
) {
var login by rememberSaveable { mutableStateOf("") }
@@ -110,52 +90,56 @@ fun LoginPanel(
var loading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(
modifier
.padding(32.dp, 0.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
modifier = Modifier.padding(8.dp),
value = login,
onValueChange = { login = it },
singleLine = true,
label = { Text(stringResource(R.string.prompt_login))},
placeholder = { Text("s23380101") }
)
Box(Modifier.fillMaxSize(), Alignment.BottomCenter) {
Column(
modifier
.padding(32.dp, 0.dp)
.clip(RoundedCornerShape(10.dp, 10.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(16.dp)
) {
TextField(
modifier = Modifier.padding(8.dp),
value = login,
onValueChange = { login = it },
singleLine = true,
label = { Text(stringResource(R.string.prompt_login)) }
)
OutlinedTextField(
modifier = Modifier.padding(8.dp),
value = password,
onValueChange = { password = it },
singleLine = true,
label = { Text(stringResource(R.string.prompt_password)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
TextField(
modifier = Modifier.padding(8.dp),
value = password,
onValueChange = { password = it },
singleLine = true,
label = { Text(stringResource(R.string.prompt_password)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Button(
modifier = Modifier.padding(8.dp),
enabled = login.trim().isNotEmpty() and password.trim().isNotEmpty() and !loading,
onClick = {
Button(modifier = Modifier
.fillMaxWidth()
.padding(8.dp), onClick = {
loading = true
scope.launch {
if (authRepo.login(login, password))
ok()
else
if (auth(login, password)) {
ok(login, password)
} else {
error()
}
loading = false
}
}
) {
if (loading)
CircularProgressIndicator()
else
}) {
Text(stringResource(R.string.sign_in))
}
}
}
}
@Preview(showBackground = true)
@Composable
fun LoginPreview() {
UNNTheme {
LoginPanel(ok = { _, _ -> }, error = {})
}
}
@@ -0,0 +1,183 @@
package ru.sweetbread.unn.ui.layout
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.kizitonwose.calendar.compose.WeekCalendar
import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.cache.HttpCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.sweetbread.unn.ui.Auditorium
import ru.sweetbread.unn.ui.Building
import ru.sweetbread.unn.ui.Discipline
import ru.sweetbread.unn.ui.KindOfWork
import ru.sweetbread.unn.ui.Lecturer
import ru.sweetbread.unn.ui.LecturerRank
import ru.sweetbread.unn.ui.ScheduleUnit
import ru.sweetbread.unn.ui.auth
import ru.sweetbread.unn.ui.getSchedule
import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.activities.start
import splitties.preferences.Preferences
import splitties.toast.toast
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import io.ktor.client.plugins.logging.*
import ru.sweetbread.unn.ui.composes.Schedule
import ru.sweetbread.unn.ui.composes.ScheduleDay
object LoginData : Preferences("loginData") {
var login by stringPref("login", "")
var password by stringPref("password", "")
}
val client = HttpClient {
install(HttpCache)
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.i("Ktor", message)
}
}
level = LogLevel.ALL
}
install(HttpTimeout) {
socketTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
}
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
modifyRequest { request ->
request.headers.append("x-retry-count", retryCount.toString())
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (LoginData.login.isEmpty() or LoginData.password.isEmpty()) start<LoginActivity>()
runBlocking {
if (!auth()) start<LoginActivity>()
}
setContent {
UNNTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
onClick = { toast("Not implemented") },
icon = {
Icon(
Icons.Filled.Home,
contentDescription = "Home"
)
},
selected = navController.currentDestination?.route?.startsWith("home") ?: false
)
NavigationBarItem(
onClick = { navController.navigate("schedule/student/me/today")
Log.d("route", navController.currentDestination?.route.toString())},
icon = {
Icon(
Icons.Filled.DateRange,
contentDescription = "Schedule"
)
},
selected = navController.currentDestination?.route?.startsWith("schedule") ?: false
)
NavigationBarItem(
onClick = { toast("Not implemented") },
icon = {
Icon(
Icons.Filled.AccountBox,
contentDescription = "Account"
)
},
selected = false
)
}
}
) {innerPadding ->
Box(Modifier.padding(innerPadding)) {
NavHost(navController, startDestination = "home/blogposts") {
composable("home/blogposts") {
Text("Not implemented")
}
composable("schedule/{type}/{who}/{when}",
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("who") { type = NavType.StringType },
navArgument("when") { type = NavType.StringType },)
) {
Schedule()
}
}
}
}
}
}
}
}
}
@@ -1,5 +1,3 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.theme
import androidx.compose.ui.graphics.Color
@@ -1,14 +1,12 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@@ -1,5 +1,3 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.theme
import androidx.compose.material3.Typography
@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
@@ -1,5 +1,3 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 B

After

Width:  |  Height:  |  Size: 942 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

-2
View File
@@ -1,5 +1,3 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<dimen name="activity_horizontal_margin">48dp</dimen>
</resources>
-29
View File
@@ -1,35 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<string name="app_name_reg">ННГУ</string>
<string name="app_name_dev">ННГУ Альфа</string>
<string name="app_name_beta">ННГУ Бета</string>
<string name="prompt_password">Пароль</string>
<string name="prompt_login">Логин</string>
<string name="sign_in">Войти</string>
<string name="assistant">Ассистент</string>
<string name="lecturer">Преподаватель</string>
<string name="slecturer">Старший Преподаватель</string>
<string name="aprofessor">Доцент</string>
<string name="auditorium">Аудитория</string>
<string name="building">Здание</string>
<string name="floor">Этаж</string>
<string name="noData">Нет данных</string>
<string name="news">Новости</string>
<string name="schedule">Расписание</string>
<string name="record_book">Зачётная книга</string>
<string name="about_app">О приложении</string>
<string name="documents">Документы</string>
<string name="materials">Материалы</string>
<string-array name="short_weekdays">
<item>Пн</item>
<item>Вт</item>
<item>Ср</item>
<item>Чт</item>
<item>Пт</item>
<item>Сб</item>
</string-array>
<string name="student">Студент</string>
<string name="employee">Сотрудник</string>
</resources>
@@ -1,5 +1,3 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<dimen name="activity_horizontal_margin">200dp</dimen>
</resources>
@@ -1,5 +1,3 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<dimen name="activity_horizontal_margin">48dp</dimen>
</resources>
-2
View File
@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
-2
View File
@@ -1,5 +1,3 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<color name="ic_launcher_background">#1565AA</color>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
+3 -31
View File
@@ -1,37 +1,9 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<string name="app_name_reg">UNN</string>
<string name="app_name_dev">UNN Dev</string>
<string name="app_name_beta">UNN Beta</string>
<string name="app_name" translatable="false">UNN</string>
<!-- <string name="title_activity_login">LoginActivity</string>-->
<string name="prompt_email" translatable="false">Email</string>
<string name="prompt_login">Login</string>
<string name="prompt_password">Password</string>
<string name="sign_in">Sign in</string>
<string name="assistant">Assistant</string>
<string name="lecturer">Lecturer</string>
<string name="slecturer">Senior Lecturer</string>
<string name="aprofessor">Assistant professor</string>
<string name="auditorium">Auditorium</string>
<string name="building">Building</string>
<string name="floor">Floor</string>
<string name="lorem" translatable="false">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vel iaculis elit. Aliquam varius urna ut nisl rhoncus ullamcorper. Maecenas et nisl at dui mollis maximus nec in libero. Ut eu nulla id felis hendrerit lobortis. Maecenas vel facilisis lectus. Morbi eleifend massa a ante consequat, eu aliquam elit euismod. Aenean quis erat tincidunt, egestas ligula id, convallis tortor. Vivamus volutpat condimentum nisl sed eleifend. Aenean dapibus dolor ut orci lobortis, placerat lobortis tortor pretium. Nam eros lectus, convallis sed ultricies sit amet, lacinia sed sem. In mi odio, porta non malesuada et, cursus a metus. Morbi quis odio sed quam commodo gravida id sit amet dolor. Donec ac iaculis massa. Nulla mauris sapien, auctor consequat est in, tempus accumsan ipsum. Donec semper volutpat nisi. Quisque dignissim tellus ipsum, sed malesuada libero aliquam sed. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam eleifend pharetra orci eu scelerisque. In hac habitasse platea dictumst. Sed non neque vitae metus porttitor vestibulum ut eget felis. Aliquam venenatis a magna eu mattis. Proin rutrum, sapien id viverra finibus, nisi quam aliquam eros, et dignissim lectus sem sit amet purus. Donec et semper enim, sed pretium lacus. Nullam venenatis ullamcorper maximus. Mauris pellentesque velit non sem sollicitudin molestie. Duis hendrerit consequat enim eget euismod.</string>
<string name="noData">No Data</string>
<string name="news">News</string>
<string name="schedule">Schedule</string>
<string name="student">Student</string>
<string name="employee">Employee</string>
<string name="record_book">Record book</string>
<string name="about_app">About app</string>
<string name="documents">Documents</string>
<string name="materials">Materials</string>
<string name="developer">Developer</string>
<string-array name="short_weekdays">
<item>Mn</item>
<item>Tu</item>
<item>We</item>
<item>Th</item>
<item>Fr</item>
<item>Sa</item>
</string-array>
<!-- <string name="login_failed">"Login failed"</string>-->
</resources>
+1 -3
View File
@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources>
<style name="Theme.UNN" parent="android:Theme.Material.NoActionBar" />
<style name="Theme.UNN" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
+4 -7
View File
@@ -1,8 +1,5 @@
// 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.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.sentry.android) apply false
}
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.jetbrainsKotlinAndroid) apply false
}
+20 -4
View File
@@ -1,7 +1,23 @@
# 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
org.gradle.caching=true
# 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
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.nonTransitiveRClass=true
# 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
+39 -47
View File
@@ -1,43 +1,36 @@
[versions]
agp = "9.3.2"
coil = "3.6.2"
coreSplashscreen = "1.2.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.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"
paging = "3.5.1"
agp = "8.3.0"
datastorePreferences = "1.0.0"
kotlin = "1.9.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
ktorClientCio = "2.3.9"
ktorClientCore = "2.3.9"
ktorClientLogging = "2.3.9"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.7.0"
composeBom = "2023.08.00"
appcompat = "1.6.1"
material = "1.10.0"
annotation = "1.6.0"
constraintlayout = "2.1.4"
lifecycleLivedataKtx = "2.6.1"
lifecycleViewmodelKtx = "2.6.1"
activity = "1.8.0"
navigationCompose = "2.7.7"
splittiesFunPackAndroidBaseWithViewsDsl = "3.0.0"
[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 = "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" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
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-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-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
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" }
@@ -46,20 +39,19 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
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-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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
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-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" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktorClientCio" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorClientLogging" }
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 = "lifecycleLivedataKtx" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
splitties-fun-pack-android-base-with-views-dsl = { module = "com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl", version.ref = "splittiesFunPackAndroidBaseWithViewsDsl" }
[plugins]
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" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+2 -5
View File
@@ -1,9 +1,6 @@
# 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.5.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

Some files were not shown because too many files have changed in this diff Show More