Compare commits

47 Commits

Author SHA1 Message Date
Sweetbread a775c2799c style: replace cascade if with when 2026-02-15 01:23:51 +03:00
Sweetbread 3d61474774 feat: format group names 2026-02-15 01:22:01 +03:00
Sweetbread ae08758277 chore: change startDestination to journal/schedule 2026-02-14 23:30:43 +03:00
Sweetbread 26c4efedc7 deps: bump 2026-02-14 23:11:03 +03:00
Sweetbread 937c6104d1 fix: delete Sunday in week calendar 2026-02-14 22:36:10 +03:00
Sweetbread 31c81021ce fix: change ACRA to Sentry 2025-05-14 21:40:33 +03:00
Sweetbread 82d62132c0 wip: feat: add about page 2025-04-27 02:09:53 +03:00
Sweetbread 1a1b3677f9 fix: add padding to prevent increasing height of an element after loading data 2025-04-27 00:56:53 +03:00
Sweetbread 790a5fabcd add license 2025-04-26 14:34:07 +03:00
Sweetbread 1286860d55 ref: removing api calls to separate functions 2025-04-26 14:34:06 +03:00
Sweetbread 654acf1b77 feat: add side menu 2025-04-25 22:15:38 +03:00
Sweetbread 0cad16ac74 impr: week field
- remove Sunday
- make current day rounded
- show names of days of a week
2025-04-24 22:53:48 +03:00
Sweetbread 0c57d0b7e7 feat: add TopAppBar 2025-04-24 21:46:41 +03:00
Sweetbread bc7e2b4100 fixup! impr: add animation 2025-04-24 21:00:04 +03:00
Sweetbread 7f613a2106 impr: show images in blogposts 2025-04-24 20:56:54 +03:00
Sweetbread 99effd1e52 deps: update 2025-04-24 20:56:54 +03:00
Sweetbread 764eedc837 impr: add animation 2025-04-24 20:56:53 +03:00
Sweetbread 90d1f69f0d impr: LoginActivity.kt
- Style changed
- Added loading indicator
- Added login/password validation
2025-04-24 20:56:53 +03:00
Sweetbread 7bfcb3d3c9 feat: add marked divider 2025-04-24 20:56:53 +03:00
Sweetbread 57bca56d2b style: simplify db declaration 2025-04-23 23:19:06 +03:00
Sweetbread ad8ef6dca8 fix: builds variants names 2025-04-23 23:18:48 +03:00
Sweetbread 33959936d6 fix: apply warning fixes 2025-04-23 23:18:48 +03:00
Sweetbread e9a200d543 impr: add empty list message 2025-04-23 23:18:48 +03:00
Sweetbread 282c93b5d4 fix: apply API changes 2025-04-23 23:18:48 +03:00
Sweetbread c63ca8c1da deps: Update 2024-09-08 21:23:38 +03:00
Sweetbread 4c1a9c8765 fixup! perf: Make network calls async 2024-09-08 20:12:22 +03:00
Sweetbread 5b6660aa8d fixup! feat(blogs): Extended items 2024-09-08 20:11:42 +03:00
sweetbread 46987c6fa5 feat(cache): Add conflict strategy 2024-09-03 21:04:03 +03:00
sweetbread 3138afb0de perf: Make network calls async 2024-09-03 21:04:03 +03:00
sweetbread 455a3cae4c feat(cache): Cache ScheduleItem 2024-09-03 21:03:20 +03:00
sweetbread 172f997af6 feat(cache): Cache users 2024-09-03 21:03:20 +03:00
sweetbread dd88d4a81a feat(blogs): Change colors 2024-05-26 18:42:31 +03:00
sweetbread 8b57881e28 feat(blogs): Change conversion to HTML 2024-05-26 18:42:31 +03:00
sweetbread 9e1e64f3fc feat(blogs): Extended items 2024-03-27 20:44:57 +03:00
sweetbread 83759f5a37 deps: Update 2024-03-26 19:59:28 +03:00
sweetbread da7498bf02 Remove API.kt and UNNApp.kt from .ui 2024-03-26 19:59:28 +03:00
sweetbread a417d74154 Remove .idea from repo 2024-03-26 19:59:28 +03:00
sweetbread c09947865d wip: buildTypes 2024-03-26 19:59:28 +03:00
sweetbread d4bb804f24 build: Add version prefixes 2024-03-26 19:59:28 +03:00
sweetbread fd49b30efb fix: io.ktor.client.network.sockets.ConnectTimeoutException 2024-03-26 19:59:28 +03:00
sweetbread 6e85ca4bdc feat(blogs): Add markup 2024-03-26 19:59:27 +03:00
sweetbread 3cf7885d9e feat: Blogs 2024-03-26 19:59:27 +03:00
sweetbread 0d4ba49111 security: Hide secrets for ACRA 2024-03-23 12:51:05 +03:00
sweetbread 6efb5998ae feat: ACRA 2024-03-20 20:32:25 +03:00
sweetbread 284e841e6c bug: LoginActivity now starts MainActivity 2024-03-20 20:32:25 +03:00
sweetbread 524bab9d6e Add extended ScheduleItem 2024-03-20 20:17:13 +03:00
sweetbread 983249091a Init commit 2024-03-19 19:18:48 +03:00
74 changed files with 2528 additions and 709 deletions
+2 -6
View File
@@ -1,15 +1,11 @@
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/.idea/caches /.idea/
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build /build
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
secrets.properties
-3
View File
@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
-123
View File
@@ -1,123 +0,0 @@
<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
@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>
-19
View File
@@ -1,19 +0,0 @@
<?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
@@ -1,19 +0,0 @@
<?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
@@ -1,6 +0,0 @@
<?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
@@ -1,10 +0,0 @@
<?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
@@ -1,9 +0,0 @@
<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
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+15
View File
@@ -0,0 +1,15 @@
<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
@@ -0,0 +1,70 @@
# 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.
+71 -10
View File
@@ -1,16 +1,31 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Properties
plugins { plugins {
alias(libs.plugins.androidApplication) alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid) alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.kotlin.compose)
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
id("com.google.devtools.ksp")
id("io.sentry.android.gradle") version "5.5.0"
}
secrets {
propertiesFileName = "secrets.properties"
} }
android { android {
namespace = "ru.sweetbread.unn" namespace = "ru.sweetbread.unn"
compileSdk = 34 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "ru.sweetbread.unn" applicationId = "ru.sweetbread.unn"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -18,24 +33,53 @@ android {
vectorDrawables { vectorDrawables {
useSupportLibrary = true 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 { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures { buildFeatures {
compose = true compose = true
viewBinding = true viewBinding = true
buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.1" kotlinCompilerExtensionVersion = "1.5.1"
@@ -47,10 +91,22 @@ android {
} }
} }
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
}
}
base {
archivesName = "ru.sweetbread.unn-v${android.defaultConfig.versionCode}(${android.defaultConfig.versionName})"
}
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.5.0") implementation(libs.androidx.material.icons.core.android)
coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
@@ -71,16 +127,21 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio) implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.logging) implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.android)
implementation(libs.coil.compose)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation("com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl:3.0.0") implementation(libs.splitties.base)
implementation(libs.splitties.room)
implementation("com.kizitonwose.calendar:compose:2.5.0") implementation(libs.sentry)
} implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
}
+37
View File
@@ -0,0 +1,37 @@
{
"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 package ru.sweetbread.unn
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *
+38 -6
View File
@@ -1,20 +1,56 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".UNNApp"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.UNN" android:theme="@style/Theme.UNN"
tools:targetApi="31"> 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 <activity
android:name=".ui.layout.MainActivity" android:name=".ui.layout.MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.UNN" />
<activity
android:name=".ui.layout.LoginActivity"
android:exported="true"
android:theme="@style/Theme.UNN"> android:theme="@style/Theme.UNN">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -22,10 +58,6 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.layout.LoginActivity"
android:exported="true"
android:theme="@style/Theme.UNN"/>
</application> </application>
</manifest> </manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,12 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn
import android.app.Application
import android.content.Context
class UNNApp : Application() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
}
}
@@ -0,0 +1,96 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.api
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import ru.sweetbread.unn.db.cacheSchedule
import ru.sweetbread.unn.db.loadSchedule
import ru.sweetbread.unn.ui.layout.LoginData
import java.time.LocalDate
lateinit var PHPSESSID: String
lateinit var CSRF: String
lateinit var ME: User
/**
* Authorize user by [login] and [password]
*
* Also defines local vars [PHPSESSID] and [ME]
*/
suspend fun auth(
login: String = LoginData.login,
password: String = LoginData.password,
forced: Boolean = false
): Boolean {
if (!forced) {
if (::PHPSESSID.isInitialized and ::ME.isInitialized)
return true
}
val id = getToken(login, password)
if (id != null) {
PHPSESSID = id
getMyself(login)
getCSRF()
return true
}
return false
}
/**
* Save info about current [User] in memory
*/
private suspend fun getMyself(login: String) = coroutineScope {
val idDeferred = async { getId(login) }
val userDeferred = async { getUser() }
val id = idDeferred.await()
val user = userDeferred.await()
ME = User(
unnId = id,
userId = user.userId,
type = Type.student,
email = user.email,
nameRu = user.nameRu,
nameEn = user.nameEn,
isMale = user.isMale,
birthday = user.birthday,
avatar = user.avatar
)
}
suspend fun getScheduleDay(
type: Type = ME.type,
id: Int = ME.unnId!!,
date: LocalDate
): ArrayList<ScheduleUnit> {
if ((type == ME.type) and (id == ME.unnId!!)) {
val schedule = withContext(Dispatchers.IO) { loadSchedule(date) }
Log.d("Schedule", schedule.joinToString())
if (schedule.isNotEmpty())
return schedule
}
return getSchedule(type, id, date, date)
}
suspend fun getSchedule(
type: Type = ME.type,
id: Int = ME.unnId!!,
start: LocalDate,
finish: LocalDate
): ArrayList<ScheduleUnit> {
val schedule = downloadSchedule(type, id, start, finish)
if ((type == ME.type) and (id == ME.unnId!!))
cacheSchedule(schedule)
return schedule
}
@@ -0,0 +1,250 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.api
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import io.ktor.http.parameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import ru.sweetbread.unn.db.cacheUser
import ru.sweetbread.unn.db.loadUserByBitrixId
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
const val portalURL = "https://portal.unn.ru"
const val ruzapiURL = "$portalURL/ruzapi"
const val vuzapiURL = "$portalURL/bitrix/vuz/api"
const val prtl2URL = "$portalURL/portal2/api"
const val restURL = "$portalURL/rest"
val client = HttpClient(Android) {
install(HttpCache)
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.i("Ktor", message)
}
}
level = LogLevel.ALL
}
install(HttpTimeout) {
connectTimeoutMillis = 5000
}
install(HttpRequestRetry) {
retryOnException(maxRetries = 3, retryOnTimeout = true)
exponentialDelay()
modifyRequest { request ->
request.headers.append("x-retry-count", retryCount.toString())
}
}
}
suspend fun getToken(login: String, password: String): String? {
val r = client.submitForm("$portalURL/auth/?login=yes",
formParameters = parameters {
append("AUTH_FORM", "Y")
append("TYPE", "AUTH")
append("backurl", "/")
append("USER_LOGIN", login)
append("USER_PASSWORD", password)
}
)
if (r.status.value == 302)
return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
return null
}
suspend fun getId(login: String): Int {
return JSONObject(client.get("$ruzapiURL/studentinfo/") {
header("Cookie", "PHPSESSID=$PHPSESSID")
parameter("uns", login.drop(1))
}.bodyAsText()).getString("id").toInt()
}
suspend fun getUser(userId: Int? = null): User {
// WARNING: trailing / is important, 'cuz API devs are eating shit
// TODO: make up another way to get unnId: this is not useful for lectures
val json = JSONObject(
client.get("$vuzapiURL/user/${userId ?: ""}") {
header("Cookie", "PHPSESSID=$PHPSESSID")
}.bodyAsText()
)
return User(
unnId = null,
userId = json.getInt("id"),
type = if (json.getJSONArray("profiles").getJSONObject(0)
.getString("type") == "employee"
) Type.employee else Type.student,
email = json.getString("email"),
nameRu = json.getString("fullname"),
nameEn = json.getString("fullname_en"),
isMale = json.getString("sex") == "M",
birthday = Instant
.parse(json.getString("birthdate"))
.atZone(ZoneId.of("Europe/Moscow"))
.toLocalDate(),
avatar = json.getJSONObject("photo").let {
AvatarSet(
it.getString("orig"),
it.getString("thumbnail"),
it.getString("small"),
)
}
)
}
suspend fun downloadSchedule(
type: Type = ME.type,
id: Int = ME.unnId!!,
start: LocalDate,
finish: LocalDate
): ArrayList<ScheduleUnit> {
val unnDatePattern = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val r = client.get("$ruzapiURL/schedule/${type.name}/$id") {
parameter("start", start.format(unnDatePattern))
parameter("finish", finish.format(unnDatePattern))
parameter("lng", "1")
}
val json = JSONArray(r.bodyAsText())
val out = arrayListOf<ScheduleUnit>()
for (i in 0 until json.length()) {
val unit = json.getJSONObject(i)
val lecturesJson = unit.getJSONArray("listOfLecturers")
val lecturers = arrayListOf<Lecturer>()
for (j in 0 until lecturesJson.length()) {
val lecturer = lecturesJson.getJSONObject(j)
lecturers.add(
Lecturer(
name = lecturer.getString("lecturer"),
email = lecturer.getString("lecturerEmail"),
unnId = lecturer.getInt("lecturerOid"),
uid = lecturer.getString("lecturerUID"),
rank = when (lecturer.getString("lecturer_rank")) {
"АССИСТ" -> LecturerRank.Assistant
"СТПРЕП" -> LecturerRank.SLecturer
"ДОЦЕНТ" -> LecturerRank.AProfessor
else -> LecturerRank.Lecturer
}
)
)
}
out.add(
ScheduleUnit(
oid = unit.getInt("lessonOid"),
auditorium = Auditorium(
name = unit.getString("auditorium"),
oid = unit.getInt("auditoriumOid"),
floor = unit.getInt("auditoriumfloor"),
building = Building(
name = unit.getString("building"),
gid = unit.getInt("buildingGid"),
oid = unit.getInt("buildingOid")
)
),
date = LocalDate.parse(unit.getString("date"), unnDatePattern),
discipline = Discipline(
name = unit.getString("discipline"),
oid = unit.getInt("disciplineOid"),
type = unit.getInt("disciplinetypeload")
),
kindOfWork = KindOfWork(
name = unit.getString("kindOfWork"),
complexity = unit.getInt("kindOfWorkComplexity"),
oid = unit.getInt("kindOfWorkOid"),
uid = unit.getString("kindOfWorkUid")
),
lecturers = lecturers,
stream = unit.getString("stream"),
begin = LocalTime.parse(
unit.getString("beginLesson"),
DateTimeFormatter.ofPattern("HH:mm")
),
end = LocalTime.parse(
unit.getString("endLesson"),
DateTimeFormatter.ofPattern("HH:mm")
)
)
)
}
return out
}
suspend fun getCSRF() {
val r = client.get("$restURL/log.blogpost.get") {
header("Cookie", "PHPSESSID=$PHPSESSID")
parameter("sessid", "")
}
CSRF = JSONObject(r.bodyAsText()).getString("sessid")
}
suspend fun getUserByBitrixId(id: Int): User {
withContext(Dispatchers.IO) {
loadUserByBitrixId(id)
}?.let { return it }
val userId = JSONObject(client.get("$vuzapiURL/user/bx/$id") {
header("Cookie", "PHPSESSID=$PHPSESSID")
}.bodyAsText()).getInt("id")
getUser(userId).let { user ->
withContext(Dispatchers.IO) {
cacheUser(user)
}
return user
}
}
suspend fun getBlogposts(): ArrayList<Post> {
val r = client.get("$prtl2URL/news.php") {
header("Cookie", "PHPSESSID=$PHPSESSID")
header("x-bitrix-sessid-token", CSRF)
}
val result = JSONArray(r.bodyAsText())
val out = arrayListOf<Post>()
for (i in 0 until result.length()) {
val el = result.getJSONObject(i)
out.add(
Post(
id = el.getString("id").toInt(),
authorId = el.getJSONObject("author").getInt("id").toInt(),
enableComments = true, // FIXME: Delete the field or get correct value
numComments = el.getString("commentsnum").toInt(),
date = LocalDateTime.parse(
el.getString("time"),
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss")
),
content = el.getString("fulltext")
)
)
}
return out
}
@@ -0,0 +1,97 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.api
import ru.sweetbread.unn.R
import splitties.resources.appStr
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
enum class Type(val s: String) {
student(appStr(R.string.student)),
group("group"),
lecturer(appStr(R.string.lecturer)),
auditorium("auditorium"),
employee(appStr(R.string.employee))
}
enum class LecturerRank(val id: Int) {
Assistant(R.string.assistant),
Lecturer(R.string.lecturer),
SLecturer(R.string.slecturer),
AProfessor(R.string.aprofessor)
}
class ScheduleUnit(
val oid: Int,
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 unnId: Int,
val uid: String
)
class User(
val unnId: Int?,
val userId: Int,
val type: Type,
val email: String,
val nameRu: String,
val nameEn: String,
val isMale: Boolean,
val birthday: LocalDate,
val avatar: AvatarSet
)
class Post(
val id: Int,
val authorId: Int,
val enableComments: Boolean,
val numComments: Int,
val date: LocalDateTime,
val content: String
)
class AvatarSet(
val original: String,
val thumbnail: String,
val small: String
)
@@ -0,0 +1,29 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.db
import androidx.room.Database
import androidx.room.RoomDatabase
import splitties.arch.room.roomDb
val cacheDb = roomDb<AppDatabase>(name = "cache") {
fallbackToDestructiveMigration(dropAllTables = true)
}
@Database(entities = [
UserDB::class,
BuildingDB::class,
AuditoriumDB::class,
DisciplineDB::class,
KindOfWorkDB::class,
LecturerDB::class,
ScheduleUnitDB::class], version = 1)
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
}
@@ -0,0 +1,385 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.db
import android.util.Log
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import ru.sweetbread.unn.api.Auditorium
import ru.sweetbread.unn.api.Building
import ru.sweetbread.unn.api.Discipline
import ru.sweetbread.unn.api.KindOfWork
import ru.sweetbread.unn.api.Lecturer
import ru.sweetbread.unn.api.LecturerRank
import ru.sweetbread.unn.api.ScheduleUnit
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@Entity
data class BuildingDB(
@PrimaryKey val oid: Int,
@ColumnInfo val name: String,
@ColumnInfo val gid: Int,
@ColumnInfo val expiredAt: String
)
@Dao
interface BuildingDao {
@Query("SELECT * FROM buildingdb WHERE oid = :oid LIMIT 1")
fun get(oid: Int): BuildingDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(building: BuildingDB)
@Delete
fun delete(building: BuildingDB)
}
fun cacheBuilding(building: Building) {
cacheDb.buildingDao().insert(
BuildingDB(
building.oid,
building.name,
building.gid,
LocalDateTime.now().plusMonths(1).format(DateTimeFormatter.ISO_DATE_TIME)
)
)
}
fun loadBuilding(oid: Int): Building? {
return cacheDb.buildingDao().get(oid)?.let {
if (LocalDateTime.parse(
it.expiredAt,
DateTimeFormatter.ISO_DATE_TIME
) > LocalDateTime.now()
) {
cacheDb.buildingDao().delete(it)
return null
}
Building(
it.name,
it.gid,
it.oid
)
}
}
@Entity
data class AuditoriumDB(
@PrimaryKey val oid: Int,
@ColumnInfo val name: String,
@ColumnInfo val floor: Int?,
@ColumnInfo val buildingOid: Int,
@ColumnInfo val expiredAt: String
)
@Dao
interface AuditoriumDao {
@Query("SELECT * FROM auditoriumdb WHERE oid = :oid LIMIT 1")
fun get(oid: Int): AuditoriumDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(auditorium: AuditoriumDB)
@Delete
fun delete(auditorium: AuditoriumDB)
}
fun cacheAuditorium(auditorium: Auditorium) {
cacheBuilding(auditorium.building)
cacheDb.auditoriumDao().insert(
AuditoriumDB(
auditorium.oid,
auditorium.name,
auditorium.floor,
auditorium.building.oid,
LocalDateTime.now().plusMonths(1).format(DateTimeFormatter.ISO_DATE_TIME)
)
)
}
fun loadAuditorium(oid: Int): Auditorium? {
return cacheDb.auditoriumDao().get(oid)?.let {
if (LocalDateTime.parse(
it.expiredAt,
DateTimeFormatter.ISO_DATE_TIME
) > LocalDateTime.now()
) {
cacheDb.auditoriumDao().delete(it)
return null
}
val building = loadBuilding(it.buildingOid) ?: return null
return Auditorium(
it.name,
it.oid,
it.floor ?: 0,
building
)
}
}
@Entity
data class DisciplineDB(
@PrimaryKey val oid: Int,
@ColumnInfo val name: String,
@ColumnInfo val type: Int,
@ColumnInfo val expiredAt: String
)
@Dao
interface DisciplineDao {
@Query("SELECT * FROM disciplinedb WHERE oid = :oid LIMIT 1")
fun get(oid: Int): DisciplineDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(discipline: DisciplineDB)
@Delete
fun delete(discipline: DisciplineDB)
}
fun cacheDiscipline(discipline: Discipline) {
cacheDb.disciplineDao().insert(
DisciplineDB(
discipline.oid,
discipline.name,
discipline.type,
LocalDateTime.now().plusMonths(1).format(DateTimeFormatter.ISO_DATE_TIME)
)
)
}
fun loadDiscipline(oid: Int): Discipline? {
return cacheDb.disciplineDao().get(oid)?.let {
if (LocalDateTime.parse(
it.expiredAt,
DateTimeFormatter.ISO_DATE_TIME
) > LocalDateTime.now()
) {
cacheDb.disciplineDao().delete(it)
return null
}
return Discipline(
it.name,
it.oid,
it.type
)
}
}
@Entity
data class KindOfWorkDB(
@PrimaryKey val oid: Int,
@ColumnInfo val name: String,
@ColumnInfo val uid: String,
@ColumnInfo val complexity: Int,
@ColumnInfo val expiredAt: String
)
@Dao
interface KindOfWorkDao {
@Query("SELECT * FROM kindofworkdb WHERE oid = :oid LIMIT 1")
fun get(oid: Int): KindOfWorkDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(kindOfWork: KindOfWorkDB)
@Delete
fun delete(kindOfWork: KindOfWorkDB)
}
fun cacheKindOfWork(kindOfWork: KindOfWork) {
cacheDb.kindOfWorkDao().insert(
KindOfWorkDB(
kindOfWork.oid,
kindOfWork.name,
kindOfWork.uid,
kindOfWork.complexity,
LocalDateTime.now().plusMonths(1).format(DateTimeFormatter.ISO_DATE_TIME)
)
)
}
fun loadKindOfWork(oid: Int): KindOfWork? {
return cacheDb.kindOfWorkDao().get(oid)?.let {
if (LocalDateTime.parse(
it.expiredAt,
DateTimeFormatter.ISO_DATE_TIME
) > LocalDateTime.now()
) {
cacheDb.kindOfWorkDao().delete(it)
return null
}
return KindOfWork(
it.name,
it.oid,
it.uid,
it.complexity
)
}
}
@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 expiredAt: String
)
@Dao
interface LecturerDao {
@Query("SELECT * FROM lecturerdb WHERE unnId = :unnId LIMIT 1")
fun get(unnId: Int): LecturerDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(lecturer: LecturerDB)
@Delete
fun delete(lecturer: LecturerDB)
}
fun cacheLecturer(lecturer: Lecturer) {
cacheDb.lecturerDao().insert(
LecturerDB(
lecturer.unnId,
lecturer.name,
lecturer.rank,
lecturer.email,
lecturer.uid,
LocalDateTime.now().plusMonths(1).format(DateTimeFormatter.ISO_DATE_TIME)
)
)
}
fun loadLecturer(unnId: Int): Lecturer? {
return cacheDb.lecturerDao().get(unnId)?.let {
if (LocalDateTime.parse(
it.expiredAt,
DateTimeFormatter.ISO_DATE_TIME
) > LocalDateTime.now()
) {
cacheDb.lecturerDao().delete(it)
return null
}
return Lecturer(
it.name,
it.rank,
it.email,
it.unnId,
it.uid
)
}
}
@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 lecturerId: Int, // TODO: many-to-many
@ColumnInfo val expiredAt: String
)
@Dao
interface ScheduleItemDao {
@Query("SELECT * FROM scheduleUnitDB WHERE oid = :oid LIMIT 1")
fun getSchedule(oid: Int): ScheduleUnitDB?
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
fun getSchedule(date: String): List<ScheduleUnitDB>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(user: ScheduleUnitDB)
@Delete
fun delete(user: ScheduleUnitDB)
}
fun cacheSchedule(item: ScheduleUnit) {
cacheAuditorium(item.auditorium)
cacheDiscipline(item.discipline)
cacheKindOfWork(item.kindOfWork)
cacheLecturer(item.lecturers[0])
cacheDb.scheduleDao().insert(
ScheduleUnitDB(
item.oid,
item.date.format(DateTimeFormatter.ISO_DATE),
item.stream,
item.begin.format(DateTimeFormatter.ISO_TIME),
item.end.format(DateTimeFormatter.ISO_TIME),
item.auditorium.oid,
item.discipline.oid,
item.kindOfWork.oid,
item.lecturers[0].unnId,
when {
(LocalDate.now() > item.date) -> LocalDateTime.now().plusWeeks(2)
(LocalDate.now().plusWeeks(1) > item.date) -> LocalDateTime.now().plusDays(1)
(LocalDate.now().plusWeeks(2) > item.date) -> LocalDateTime.now().plusWeeks(1)
else -> LocalDateTime.now().plusWeeks(2)
}.format(DateTimeFormatter.ISO_DATE_TIME)
)
)
}
fun cacheSchedule(items: ArrayList<ScheduleUnit>) {
for (item in items)
cacheSchedule(item)
}
fun loadSchedule(oid: Int): ScheduleUnit? {
cacheDb.scheduleDao().getSchedule(oid)?.let {
Log.d("load", it.oid.toString())
if (LocalDateTime.parse(
it.expiredAt,
DateTimeFormatter.ISO_DATE_TIME
) < LocalDateTime.now()
) {
Log.d("delete", it.oid.toString())
cacheDb.scheduleDao().delete(it)
return null
}
return ScheduleUnit(
it.oid,
loadAuditorium(it.auditoriumOid) ?: return null,
LocalDate.parse(it.date, DateTimeFormatter.ISO_DATE),
loadDiscipline(it.disciplineOid) ?: return null,
loadKindOfWork(it.kindOfWorkOid) ?: return null,
arrayListOf(loadLecturer(it.lecturerId) ?: return null),
it.stream,
LocalTime.parse(it.begin, DateTimeFormatter.ISO_TIME),
LocalTime.parse(it.end, DateTimeFormatter.ISO_TIME)
)
}
return null
}
fun loadSchedule(date: LocalDate): ArrayList<ScheduleUnit> {
cacheDb.scheduleDao().getSchedule(date.format(DateTimeFormatter.ISO_DATE))
.map { Log.d("meow", "${it.oid}: ${loadSchedule(it.oid)}") }
return ArrayList(
cacheDb.scheduleDao().getSchedule(date.format(DateTimeFormatter.ISO_DATE))
.mapNotNull { loadSchedule(it.oid) }
)
}
@@ -0,0 +1,99 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.db
import android.util.Log
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import ru.sweetbread.unn.api.AvatarSet
import ru.sweetbread.unn.api.Type
import ru.sweetbread.unn.api.User
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Entity
data class UserDB(
@PrimaryKey val userId: Int,
@ColumnInfo val unnId: Int?,
@ColumnInfo val type: Type,
@ColumnInfo val email: String,
@ColumnInfo val nameRu: String,
@ColumnInfo val nameEn: String,
@ColumnInfo val isMale: Boolean,
@ColumnInfo val birthday: String,
@ColumnInfo val origAvatar: String,
@ColumnInfo val thumbAvatar: String,
@ColumnInfo val smallAvatar: String,
@ColumnInfo val expiredAt: String
)
@Dao
interface UserDao {
@Query("SELECT * FROM userDB WHERE userId = :userId LIMIT 1")
fun getUserById(userId: Int): UserDB?
@Insert
fun insert(user: UserDB)
@Delete
fun delete(user: UserDB)
}
fun cacheUser(user: User) {
try {
cacheDb.userDao().insert(
UserDB(
user.userId,
user.unnId,
user.type,
user.email,
user.nameRu,
user.nameEn,
user.isMale,
user.birthday.format(DateTimeFormatter.ISO_LOCAL_DATE),
user.avatar.original,
user.avatar.thumbnail,
user.avatar.small,
LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
)
)
} catch (_: android.database.sqlite.SQLiteConstraintException) {
}
}
fun loadUserByBitrixId(bitrixId: Int): User? {
val user = cacheDb.userDao().getUserById(bitrixId)
Log.d("UserDB", user?.nameEn ?: "None")
if (user == null) return null
if (LocalDateTime.parse(
user.expiredAt,
DateTimeFormatter.ISO_LOCAL_DATE_TIME
) < LocalDateTime.now()
) {
cacheDb.userDao().delete(user)
return null
} else {
return User(
user.unnId,
user.userId,
user.type,
user.email,
user.nameRu,
user.nameEn,
user.isMale,
LocalDate.parse(user.birthday, DateTimeFormatter.ISO_LOCAL_DATE),
AvatarSet(
user.origAvatar,
user.thumbAvatar,
user.smallAvatar
)
)
}
}
@@ -1,181 +0,0 @@
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,38 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes
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")
}
}
}
@@ -0,0 +1,342 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes
import android.graphics.drawable.Drawable
import android.text.Html
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.util.Log
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import ru.sweetbread.unn.R
import ru.sweetbread.unn.api.AvatarSet
import ru.sweetbread.unn.api.Post
import ru.sweetbread.unn.api.Type
import ru.sweetbread.unn.api.User
import ru.sweetbread.unn.api.getBlogposts
import ru.sweetbread.unn.api.getUserByBitrixId
import ru.sweetbread.unn.api.portalURL
import ru.sweetbread.unn.ui.theme.UNNTheme
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
val defUser = User(
null,
123,
Type.student,
"cool.email@domain.com",
"Джон Сигма Омегович",
"Jon Sigma Omega",
true,
LocalDate.now(),
AvatarSet(
"https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
"https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
"https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg"
)
)
@Composable
fun Blogposts(viewModel: PostViewModel = viewModel()) {
val posts by viewModel.posts.collectAsState()
var extended by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
viewModel.loadPosts()
}
if (posts.isNotEmpty()) {
Log.d("Another fuck", posts.size.toString())
LazyColumn {
items(posts) {
PostItem(
Modifier
.padding(8.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.clickable {
extended = if (extended == it.id) 0 else it.id
},
post = it,
extended == it.id
)
}
}
} else {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
trackColor = MaterialTheme.colorScheme.secondary,
)
}
}
class PostRepository {
suspend fun loadPosts(): List<Post> {
return getBlogposts()
}
}
class PostViewModel : ViewModel() {
private val repository = PostRepository()
private val _posts = MutableStateFlow<List<Post>>(emptyList())
val posts: StateFlow<List<Post>> = _posts.asStateFlow()
suspend fun loadPosts() {
_posts.value = repository.loadPosts()
}
}
@Composable
@NonRestartableComposable
fun UserItem(modifier: Modifier = Modifier, user: User, info: String? = null) {
Row(
modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
AsyncImage(
modifier = Modifier
.padding(end = 8.dp)
.size(48.dp)
.clip(RoundedCornerShape(50)),
model = portalURL + user.avatar.thumbnail,
contentDescription = user.nameEn
)
Column {
Text(user.nameRu, fontWeight = FontWeight.Bold)
if (!info.isNullOrBlank())
Text(
text = info,
fontStyle = FontStyle.Italic,
fontSize = MaterialTheme.typography.labelLarge.fontSize
)
}
}
}
@Composable
@NonRestartableComposable
fun PostItem(modifier: Modifier = Modifier, post: Post, extended: Boolean = false) {
var user: User? by remember { mutableStateOf(null) }
var html: String by remember { mutableStateOf("") }
LaunchedEffect(post) {
html = post.content
user = getUserByBitrixId(post.authorId)
}
Column(modifier.padding(16.dp)) {
if (user != null)
UserItem(user = user!!)
else
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 38.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
trackColor = MaterialTheme.colorScheme.secondary,
)
val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
val linkColor = MaterialTheme.colorScheme.primary.toArgb()
AndroidView(
modifier = Modifier,
factory = {
TextView(it).apply {
movementMethod = LinkMovementMethod.getInstance()
autoLinkMask = Linkify.WEB_URLS
linksClickable = true
setTextColor(textColor)
setLinkTextColor(linkColor)
}
},
update = {
it.maxLines = if (extended) Int.MAX_VALUE else 5
it.text = Html.fromHtml(
html,
Html.FROM_HTML_MODE_LEGACY,
CoilImageGetter(it),
null
)
}
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.onBackground
)
Text(text = post.date.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)))
}
}
@Preview
@Composable
fun UserItemPreview() {
UNNTheme {
Surface {
UserItem(
Modifier
.width(300.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
defUser,
Type.student.s
)
}
}
}
@Preview
@Composable
fun PostItemPreview() {
val post = Post(
id = 154923,
authorId = 165945,
enableComments = true,
numComments = 0,
date = LocalDateTime.of(2024, 3, 20, 18, 55, 20),
content = stringResource(id = R.string.lorem)
)
UNNTheme {
Surface {
PostItem(
Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer), post
)
}
}
}
class CoilImageGetter(
private val textView: TextView,
private val maxImageWidth: Int = textView.width
) : Html.ImageGetter {
override fun getDrawable(source: String): Drawable {
val urlDrawable = UrlDrawable()
if (maxImageWidth <= 0)
textView.post { updateImage(source, urlDrawable, textView.width) }
else
updateImage(source, urlDrawable, maxImageWidth)
return urlDrawable
}
private fun updateImage(source: String, urlDrawable: UrlDrawable, maxWidth: Int) {
val imageLoader = ImageLoader.Builder(textView.context)
.build()
val request = ImageRequest.Builder(textView.context)
.data(source)
.target { drawable ->
val (scaledWidth, scaledHeight) = calculateScaledSize(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
maxWidth
)
drawable.setBounds(0, 0, scaledWidth, scaledHeight)
urlDrawable.drawable = drawable
urlDrawable.setBounds(0, 0, scaledWidth, scaledHeight)
textView.text = textView.text
}
.build()
imageLoader.enqueue(request)
}
private fun calculateScaledSize(
originalWidth: Int,
originalHeight: Int,
maxWidth: Int
): Pair<Int, Int> {
if (originalWidth <= maxWidth)
return Pair(originalWidth, originalHeight)
val ratio = maxWidth.toFloat() / originalWidth.toFloat()
return Pair(
maxWidth,
(originalHeight * ratio).toInt()
)
}
}
class UrlDrawable() : Drawable() {
var drawable: Drawable? = null
set(value) {
field = value
invalidateSelf()
}
override fun draw(canvas: android.graphics.Canvas) {
drawable?.draw(canvas)
}
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
}
@@ -0,0 +1,50 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes
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 = 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,18 +1,23 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes package ru.sweetbread.unn.ui.composes
import android.util.Log import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -20,6 +25,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -27,64 +34,50 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex 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.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.sweetbread.unn.ui.Auditorium import ru.sweetbread.unn.R
import ru.sweetbread.unn.ui.Building import ru.sweetbread.unn.api.Auditorium
import ru.sweetbread.unn.ui.Discipline import ru.sweetbread.unn.api.Building
import ru.sweetbread.unn.ui.KindOfWork import ru.sweetbread.unn.api.Discipline
import ru.sweetbread.unn.ui.Lecturer import ru.sweetbread.unn.api.KindOfWork
import ru.sweetbread.unn.ui.LecturerRank import ru.sweetbread.unn.api.Lecturer
import ru.sweetbread.unn.ui.ScheduleUnit import ru.sweetbread.unn.api.LecturerRank
import ru.sweetbread.unn.ui.getSchedule import ru.sweetbread.unn.api.ScheduleUnit
import ru.sweetbread.unn.api.getScheduleDay
import ru.sweetbread.unn.ui.theme.UNNTheme import ru.sweetbread.unn.ui.theme.UNNTheme
import java.time.DayOfWeek import splitties.resources.appStr
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Calendar
@Composable @Composable
fun Schedule() { fun Schedule() {
val state = rememberWeekCalendarState( var selectedDate by remember { mutableStateOf(LocalDate.now()) }
firstDayOfWeek = DayOfWeek.MONDAY
)
Column { Column {
var curDate by remember { mutableStateOf(LocalDate.now()) } BoundedWeekPicker(
WeekCalendar( modifier = Modifier
state = state, .fillMaxWidth()
dayContent = { .padding(vertical = 8.dp),
Box( selectedDate = selectedDate,
modifier = Modifier onDateSelected = { selectedDate = it }
.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)
ScheduleDay(date = selectedDate)
} }
} }
@@ -93,13 +86,20 @@ fun ScheduleDay(modifier: Modifier = Modifier, date: LocalDate) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var loadedDate by remember { mutableStateOf(LocalDate.MIN) } var loadedDate by remember { mutableStateOf(LocalDate.MIN) }
val lessons = remember { mutableListOf<ScheduleUnit>() } val lessons = remember { mutableListOf<ScheduleUnit>() }
var expanded by remember { mutableIntStateOf(0) }
if (loadedDate == date) { if (loadedDate == date) {
Log.d("Loaded", "${date.format(DateTimeFormatter.ISO_DATE)} ${lessons.size}") Log.d("Loaded", "${date.format(DateTimeFormatter.ISO_DATE)} ${lessons.size}")
LazyColumn (modifier) { LazyColumn (modifier) {
items(lessons) { items(lessons) {
ScheduleItem(unit = it) ScheduleItem(unit = it, modifier = Modifier.clickable {
expanded = if (it.oid == expanded) 0
else it.oid
}, expanded = expanded == it.oid)
} }
if (lessons.isEmpty())
item { Text(appStr(R.string.noData)) }
} }
} else { } else {
LinearProgressIndicator( LinearProgressIndicator(
@@ -112,53 +112,223 @@ fun ScheduleDay(modifier: Modifier = Modifier, date: LocalDate) {
LaunchedEffect(date != loadedDate) { LaunchedEffect(date != loadedDate) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
lessons.clear() lessons.clear()
lessons.addAll(getSchedule(start = date, finish = date)) lessons.addAll(getScheduleDay(date = date))
loadedDate = 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 @Composable
fun ScheduleItem(modifier: Modifier = Modifier, unit: ScheduleUnit) { 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 { mutableFloatStateOf(getRatio()) }
LaunchedEffect(Unit) {
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)
ratio = getRatio()
}
}
val backgroundColor by animateColorAsState(
targetValue = when (ratio) {
1f -> MaterialTheme.colorScheme.surfaceContainer
-1f -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.primaryContainer
},
label = "backgroundTransition"
)
Row ( Row (
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(4.dp) .padding(4.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer) .background(backgroundColor)
.padding(8.dp) .padding(8.dp)
){ ){
Column (Modifier.weight(1f)) { Column (Modifier.weight(1f)) {
Text( Column {
text = unit.discipline.name, Text(
fontWeight = FontWeight.Bold, text = unit.discipline.name,
modifier = Modifier.zIndex(1f), fontWeight = FontWeight.Bold,
maxLines = 1, modifier = Modifier.zIndex(1f),
overflow = TextOverflow.Ellipsis maxLines = if (expanded) Int.MAX_VALUE else 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) 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())
}
}
} }
} }
Column { AnimatedVisibility (!expanded) {
val begin = unit.begin.format(DateTimeFormatter.ofPattern("HH:mm")) Column {
val end = unit.end.format(DateTimeFormatter.ofPattern("HH:mm")) Text(begin.toString(), fontWeight = FontWeight.Bold)
Text(begin.toString(), fontWeight = FontWeight.Bold) Text(end.toString())
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 @Preview
@Composable @Composable
fun ScheduleItemPreview() { fun ScheduleItemPreview() {
val unit = ScheduleUnit( val unit = ScheduleUnit(
oid = 1,
Auditorium( Auditorium(
name = "с/з 1(110)", name = "с/з 1(110)",
oid = 3752, oid = 3752,
@@ -186,8 +356,8 @@ fun ScheduleItemPreview() {
name = "Фамилия Имя Отчество", name = "Фамилия Имя Отчество",
rank = LecturerRank.SLecturer, rank = LecturerRank.SLecturer,
email = "", email = "",
oid = 28407, unnId = 28000,
uid = "51769" uid = "51000"
) )
), ),
stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP", stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP",
@@ -196,9 +366,56 @@ fun ScheduleItemPreview() {
) )
UNNTheme { UNNTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colorScheme.background) { Surface(color = MaterialTheme.colorScheme.background) {
ScheduleItem(unit = unit) 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)
)
UNNTheme {
Surface(color = MaterialTheme.colorScheme.background) {
ScheduleItem(unit = unit, expanded = true)
}
}
} }
@@ -0,0 +1,141 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes
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,3 +1,5 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.layout package ru.sweetbread.unn.ui.layout
import android.os.Bundle import android.os.Bundle
@@ -7,19 +9,20 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -36,14 +39,30 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.sweetbread.unn.R import ru.sweetbread.unn.R
import ru.sweetbread.unn.ui.auth import ru.sweetbread.unn.api.auth
import ru.sweetbread.unn.ui.theme.UNNTheme import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.activities.start import splitties.activities.start
import splitties.preferences.Preferences
object LoginData : Preferences("loginData") {
var login by stringPref("login", "")
var password by stringPref("password", "")
}
class LoginActivity : ComponentActivity() { class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if ((LoginData.login != "") and (LoginData.password != ""))
runBlocking {
if (auth()) {
start<MainActivity>()
finish()
}
}
setContent { setContent {
UNNTheme { UNNTheme {
Surface( Surface(
@@ -58,20 +77,26 @@ class LoginActivity : ComponentActivity() {
SnackbarHost(hostState = snackbarHostState) SnackbarHost(hostState = snackbarHostState)
} }
) { innerPadding -> ) { innerPadding ->
LoginPanel(Modifier.padding(innerPadding), { login, password -> Box(Modifier.padding(innerPadding).fillMaxSize(), Alignment.Center) {
LoginData.login = login LoginPanel(
LoginData.password = password Modifier.imePadding(),
start<MainActivity>() { login, password ->
}, { LoginData.login = login
scope.launch { LoginData.password = password
start<MainActivity>()
snackbarHostState finish()
.showSnackbar( },
message = "Error", {
duration = SnackbarDuration.Long scope.launch {
) snackbarHostState
} .showSnackbar(
}) message = "Error",
duration = SnackbarDuration.Short
)
}
}
)
}
} }
} }
} }
@@ -90,48 +115,52 @@ fun LoginPanel(
var loading by remember { mutableStateOf(false) } var loading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Box(Modifier.fillMaxSize(), Alignment.BottomCenter) { Column(
Column( modifier
modifier .padding(32.dp, 0.dp)
.padding(32.dp, 0.dp) .clip(RoundedCornerShape(10.dp))
.clip(RoundedCornerShape(10.dp, 10.dp)) .background(MaterialTheme.colorScheme.surfaceContainer)
.background(MaterialTheme.colorScheme.primaryContainer) .padding(16.dp),
.padding(16.dp) horizontalAlignment = Alignment.CenterHorizontally
) { ) {
TextField( OutlinedTextField(
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
value = login, value = login,
onValueChange = { login = it }, onValueChange = { login = it },
singleLine = true, singleLine = true,
label = { Text(stringResource(R.string.prompt_login)) } label = { Text(stringResource(R.string.prompt_login))},
) placeholder = { Text("s23380101") }
)
TextField( OutlinedTextField(
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
singleLine = true, singleLine = true,
label = { Text(stringResource(R.string.prompt_password)) }, label = { Text(stringResource(R.string.prompt_password)) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
) )
Button(modifier = Modifier Button(
.fillMaxWidth() modifier = Modifier.padding(8.dp),
.padding(8.dp), onClick = { enabled = login.trim().isNotEmpty() and password.trim().isNotEmpty() and !loading,
onClick = {
loading = true loading = true
scope.launch { scope.launch {
if (auth(login, password)) { if (auth(login, password))
ok(login, password) ok(login, password)
} else { else
error() error()
}
loading = false loading = false
} }
}) {
Text(stringResource(R.string.sign_in))
} }
) {
if (loading)
CircularProgressIndicator()
else
Text(stringResource(R.string.sign_in))
} }
} }
} }
@@ -1,177 +1,188 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.layout package ru.sweetbread.unn.ui.layout
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home 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.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextAlign
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.core.view.WindowCompat
import androidx.navigation.NavType import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import coil.compose.AsyncImage
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.launch
import kotlinx.coroutines.runBlocking import ru.sweetbread.unn.R
import ru.sweetbread.unn.ui.Auditorium import ru.sweetbread.unn.api.ME
import ru.sweetbread.unn.ui.Building import ru.sweetbread.unn.api.portalURL
import ru.sweetbread.unn.ui.Discipline import ru.sweetbread.unn.ui.composes.About
import ru.sweetbread.unn.ui.KindOfWork import ru.sweetbread.unn.ui.composes.Blogposts
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.Schedule
import ru.sweetbread.unn.ui.composes.ScheduleDay import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.resources.appStr
object LoginData : Preferences("loginData") { import splitties.toast.toast
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() { class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
if (LoginData.login.isEmpty() or LoginData.password.isEmpty()) start<LoginActivity>()
runBlocking {
if (!auth()) start<LoginActivity>()
}
setContent { setContent {
UNNTheme { UNNTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
Scaffold( ModalNavigationDrawer(
bottomBar = { drawerState = drawerState,
NavigationBar { drawerContent = {
NavigationBarItem( DrawerContent(
onClick = { toast("Not implemented") }, Modifier
icon = { .fillMaxHeight()
Icon( .fillMaxWidth(.75f)
Icons.Filled.Home, .background(MaterialTheme.colorScheme.surfaceContainer)
contentDescription = "Home" .systemBarsPadding(),
) navController = navController
}, )
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
)
}
} }
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
) {innerPadding -> Scaffold(
Box(Modifier.padding(innerPadding)) { topBar = {
NavHost(navController, startDestination = "home/blogposts") { CenterAlignedTopAppBar(
composable("home/blogposts") { title = {
Text("Not implemented") 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
)
} }
composable("schedule/{type}/{who}/{when}", }
arguments = listOf( ) { innerPadding ->
navArgument("type") { type = NavType.StringType }, Box(Modifier.padding(innerPadding)) {
navArgument("who") { type = NavType.StringType }, NavHost(navController, startDestination = "journal/schedule") {
navArgument("when") { type = NavType.StringType },) composable("portal/blogposts") {
) { Blogposts()
Schedule() }
composable("journal/schedule") {
Schedule()
}
composable("about") {
About()
}
} }
} }
} }
@@ -180,4 +191,90 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
@Composable
fun DrawerContent(modifier: Modifier = Modifier, navController: NavController) {
Column(modifier = modifier.padding(0.dp)) {
Row(
Modifier
.padding(8.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(16.dp))
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
modifier = Modifier.size(64.dp).clip(CircleShape),
model = portalURL + ME.avatar.thumbnail,
contentDescription = ME.nameEn
)
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))
}
}
}
} }
@@ -1,3 +1,5 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.theme package ru.sweetbread.unn.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -1,12 +1,14 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.theme package ru.sweetbread.unn.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -1,3 +1,5 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.theme package ru.sweetbread.unn.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
@@ -1,3 +1,5 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt" xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?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"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 942 B

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

+2
View File
@@ -1,3 +1,5 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <resources>
<dimen name="activity_horizontal_margin">48dp</dimen> <dimen name="activity_horizontal_margin">48dp</dimen>
</resources> </resources>
+29
View File
@@ -1,6 +1,35 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <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_password">Пароль</string>
<string name="prompt_login">Логин</string> <string name="prompt_login">Логин</string>
<string name="sign_in">Войти</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> </resources>
@@ -1,3 +1,5 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <resources>
<dimen name="activity_horizontal_margin">200dp</dimen> <dimen name="activity_horizontal_margin">200dp</dimen>
</resources> </resources>
@@ -1,3 +1,5 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <resources>
<dimen name="activity_horizontal_margin">48dp</dimen> <dimen name="activity_horizontal_margin">48dp</dimen>
</resources> </resources>
+2
View File
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <resources>
<color name="purple_200">#FFBB86FC</color> <color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color> <color name="purple_500">#FF6200EE</color>
+2
View File
@@ -1,3 +1,5 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <resources>
<!-- Default screen margins, per the Android Design guidelines. --> <!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen> <dimen name="activity_horizontal_margin">16dp</dimen>
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <resources>
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#1565AA</color>
</resources> </resources>
+31 -3
View File
@@ -1,9 +1,37 @@
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <resources>
<string name="app_name" translatable="false">UNN</string> <string name="app_name_reg">UNN</string>
<!-- <string name="title_activity_login">LoginActivity</string>--> <string name="app_name_dev">UNN Dev</string>
<string name="app_name_beta">UNN Beta</string>
<string name="prompt_email" translatable="false">Email</string> <string name="prompt_email" translatable="false">Email</string>
<string name="prompt_login">Login</string> <string name="prompt_login">Login</string>
<string name="prompt_password">Password</string> <string name="prompt_password">Password</string>
<string name="sign_in">Sign in</string> <string name="sign_in">Sign in</string>
<!-- <string name="login_failed">"Login failed"</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>
</resources> </resources>
+3 -1
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
<resources> <resources>
<style name="Theme.UNN" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.UNN" parent="android:Theme.Material.NoActionBar" />
</resources> </resources>
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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. Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes See https://developer.android.com/about/versions/12/backup-restore#xml-changes
+10
View File
@@ -1,5 +1,15 @@
// 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. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.androidApplication) apply false alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.jetbrainsKotlinAndroid) apply false alias(libs.plugins.jetbrainsKotlinAndroid) apply false
alias(libs.plugins.kotlin.compose) apply false
id("com.google.devtools.ksp") version "2.3.2" apply false
}
buildscript {
dependencies {
classpath(libs.secrets.gradle.plugin)
}
} }
+13 -1
View File
@@ -1,3 +1,5 @@
# Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
# Project-wide Gradle settings. # Project-wide Gradle settings.
# IDE (e.g. Android Studio) users: # IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override* # Gradle settings configured through the IDE *will override*
@@ -20,4 +22,14 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the # 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, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false
+45 -32
View File
@@ -1,57 +1,70 @@
[versions] [versions]
agp = "8.3.0" agp = "9.0.1"
datastorePreferences = "1.0.0" coilCompose = "2.7.0"
kotlin = "1.9.0" compose = "1.10.3"
coreKtx = "1.10.1" coreSplashscreen = "1.2.0"
junit = "4.13.2" datastorePreferences = "1.2.0"
junitVersion = "1.1.5" desugar_jdk_libs = "2.1.5"
espressoCore = "3.5.1" kotlin = "2.3.10"
ktorClientCio = "2.3.9" coreKtx = "1.17.0"
ktorClientCore = "2.3.9" junitVersion = "1.3.0"
ktorClientLogging = "2.3.9" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.6.1" ktor = "3.4.0"
activityCompose = "1.7.0" lifecycle = "2.10.0"
composeBom = "2023.08.00" activityCompose = "1.12.4"
appcompat = "1.6.1" composeBom = "2026.02.00"
material = "1.10.0" appcompat = "1.7.1"
annotation = "1.6.0" material = "1.13.0"
constraintlayout = "2.1.4" annotation = "1.9.1"
lifecycleLivedataKtx = "2.6.1" constraintlayout = "2.2.1"
lifecycleViewmodelKtx = "2.6.1" activity = "1.12.4"
activity = "1.8.0" navigationCompose = "2.9.7"
navigationCompose = "2.7.7" roomRuntime = "2.8.4"
splittiesFunPackAndroidBaseWithViewsDsl = "3.0.0" secretsGradlePlugin = "2.0.1"
sentryAndroid = "8.32.0"
splitties = "3.0.0"
materialIconsCoreAndroid = "1.7.8"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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-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 = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "compose" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktorClientCio" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorClientLogging" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" } androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } 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-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } 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" } secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secretsGradlePlugin" }
sentry = { module = "io.sentry:sentry-android", version.ref = "sentryAndroid" }
splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl", version.ref = "splitties" }
splitties-room = { module = "com.louiscad.splitties:splitties-arch-room", version.ref = "splitties" }
androidx-material-icons-core-android = { group = "androidx.compose.material", name = "material-icons-core-android", version.ref = "materialIconsCoreAndroid" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+3 -1
View File
@@ -1,6 +1,8 @@
# Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
#Sat Mar 16 18:30:45 MSK 2024 #Sat Mar 16 18:30:45 MSK 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+1 -15
View File
@@ -1,20 +1,6 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# # Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
############################################################################## ##############################################################################
## ##
+2
View File
@@ -1,3 +1,5 @@
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
pluginManagement { pluginManagement {
repositories { repositories {
google { google {