Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85c9593e2 |
@@ -1,11 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
secrets.properties
|
||||
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
@@ -0,0 +1,123 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<value>
|
||||
<entry key="GreetingPreview">
|
||||
<State />
|
||||
</entry>
|
||||
<entry key="GreetingPreview2">
|
||||
<State />
|
||||
</entry>
|
||||
<entry key="LoginActivity">
|
||||
<State />
|
||||
</entry>
|
||||
<entry key="app">
|
||||
<State />
|
||||
</entry>
|
||||
</value>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.0" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,15 +0,0 @@
|
||||
<UNN HUB - unofficial app providing schedule, news and document access for students>
|
||||
Copyright (C) 2025 Gleb Zaharov
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
@@ -1,70 +0,0 @@
|
||||
# NOTICE
|
||||
|
||||
This project incorporates components from the following third-party libraries:
|
||||
|
||||
## AndroidX Libraries
|
||||
- **androidx.*** (Core, Room, Navigation, Compose, etc.)
|
||||
Copyright The Android Open Source Project
|
||||
Licensed under Apache License 2.0
|
||||
[https://developer.android.com](https://developer.android.com)
|
||||
|
||||
## Coil
|
||||
- **io.coil-kt:coil-compose**
|
||||
Copyright Coil Contributors
|
||||
Licensed under Apache License 2.0
|
||||
[https://github.com/coil-kt/coil](https://github.com/coil-kt/coil)
|
||||
|
||||
## Compose Calendar
|
||||
- **com.kizitonwose.calendar:compose**
|
||||
Copyright Kizito Nwose
|
||||
Licensed under MIT License
|
||||
[https://github.com/kizitonwose/Calendar](https://github.com/kizitonwose/Calendar)
|
||||
|
||||
## Desugar JDK Libs
|
||||
- **com.android.tools:desugar_jdk_libs**
|
||||
Copyright Google LLC
|
||||
Licensed under Apache License 2.0
|
||||
|
||||
## Ktor
|
||||
- **io.ktor:ktor-***
|
||||
Copyright JetBrains s.r.o.
|
||||
Licensed under Apache License 2.0
|
||||
[https://ktor.io](https://ktor.io)
|
||||
|
||||
## Material Components
|
||||
- **com.google.android.material:material**
|
||||
Copyright Google LLC
|
||||
Licensed under Apache License 2.0
|
||||
|
||||
## Splitties
|
||||
- **com.louiscad.splitties:***
|
||||
Copyright Louis CAD
|
||||
Licensed under Apache License 2.0
|
||||
[https://github.com/LouisCAD/Splitties](https://github.com/LouisCAD/Splitties)
|
||||
|
||||
## Secrets Gradle Plugin
|
||||
- **com.google.android.libraries.mapsplatform.secrets-gradle-plugin**
|
||||
Copyright Google LLC
|
||||
Licensed under Apache License 2.0
|
||||
|
||||
## Sentry Android
|
||||
- **io.sentry:sentry-android**
|
||||
Copyright Sentry and contributors
|
||||
Licensed under MIT License
|
||||
[https://github.com/getsentry/sentry-java](https://github.com/getsentry/sentry-java)
|
||||
|
||||
## Build Tools
|
||||
- **Gradle Plugins** (Android, Kotlin)
|
||||
Copyright Google LLC / JetBrains s.r.o.
|
||||
Licensed under Apache License 2.0
|
||||
|
||||
---
|
||||
|
||||
### License Notices
|
||||
This product includes software developed by:
|
||||
- The Android Open Source Project (AOSP)
|
||||
- JetBrains s.r.o.
|
||||
- Google LLC
|
||||
- And other contributors listed above
|
||||
|
||||
Full license texts are available in the [LICENSE](LICENSE) file and respective library repositories.
|
||||
@@ -1,25 +1,16 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.sentry.android)
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
namespace = "ru.sweetbread.unn"
|
||||
compileSdk = 37
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.sweetbread.unn"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
@@ -27,53 +18,27 @@ android {
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
val secretProperties = Properties().apply {
|
||||
val secretFile = rootProject.file("secrets.properties")
|
||||
if (secretFile.exists())
|
||||
secretFile.inputStream().use { load(it) }
|
||||
else
|
||||
println("Warning: secrets.properties not found!")
|
||||
}
|
||||
manifestPlaceholders["sentry_url"] = secretProperties.getProperty("SENTRY_URL")!!
|
||||
|
||||
// javaCompileOptions {
|
||||
// annotationProcessorOptions {
|
||||
// arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
resValue("string", "app_name", "@string/app_name_reg")
|
||||
manifestPlaceholders["sentry_env"] = "production"
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix =
|
||||
"-debug+${LocalDateTime.now().format(DateTimeFormatter.BASIC_ISO_DATE)}"
|
||||
resValue("string", "app_name", "@string/app_name_dev")
|
||||
manifestPlaceholders["sentry_env"] = "debug"
|
||||
}
|
||||
create("beta") {
|
||||
versionNameSuffix =
|
||||
"-beta+${LocalDateTime.now().format(DateTimeFormatter.BASIC_ISO_DATE)}"
|
||||
resValue("string", "app_name", "@string/app_name_beta")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
manifestPlaceholders["sentry_env"] = "beta"
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
resValues = true
|
||||
viewBinding = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.1"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@@ -82,20 +47,10 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
base {
|
||||
archivesName = "ru.sweetbread.unn-v${android.defaultConfig.versionCode}(${android.defaultConfig.versionName})"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.material.icons.core)
|
||||
implementation(libs.androidx.paging.common)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.paging.room)
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.5.0")
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
@@ -103,8 +58,12 @@ dependencies {
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.annotation)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
@@ -112,21 +71,16 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.android)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.ktor3)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.splitties.base)
|
||||
implementation(libs.splitties.room)
|
||||
implementation("com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl:3.0.0")
|
||||
|
||||
implementation(libs.sentry)
|
||||
implementation("com.kizitonwose.calendar:compose:2.5.0")
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "ru.sweetbread.unn",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "ru.sweetbread.unn-v1(1.0)-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/ru.sweetbread.unn-v1(1.0)-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/ru.sweetbread.unn-v1(1.0)-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 26
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
|
||||
@@ -1,56 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".app.UNNApp"
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.UNN"
|
||||
tools:targetApi="31">
|
||||
|
||||
<meta-data
|
||||
android:name="io.sentry.dsn"
|
||||
android:value="${sentry_url}" />
|
||||
<meta-data
|
||||
android:name="io.sentry.environment"
|
||||
android:value="${sentry_env}" />
|
||||
<meta-data
|
||||
android:name="io.sentry.send-default-pii"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="io.sentry.traces.sample-rate"
|
||||
android:value="1.0" />
|
||||
<meta-data
|
||||
android:name="io.sentry.traces.user-interaction.enable"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="io.sentry.attach-screenshot"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="io.sentry.attach-view-hierarchy"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="io.sentry.session-replay.on-error-sample-rate"
|
||||
android:value="1.0" />
|
||||
<meta-data
|
||||
android:name="io.sentry.session-replay.session-sample-rate"
|
||||
android:value="0.1" />
|
||||
|
||||
<activity
|
||||
android:name=".app.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.UNN" />
|
||||
<activity
|
||||
android:name=".app.LoginActivity"
|
||||
android:name=".ui.layout.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.UNN">
|
||||
<intent-filter>
|
||||
@@ -59,6 +22,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.layout.LoginActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.UNN"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 48 KiB |
@@ -1,352 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountBox
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.sweetbread.unn.R
|
||||
import ru.sweetbread.unn.core.network.LoginResult
|
||||
import ru.sweetbread.unn.core.network.portalURL
|
||||
import ru.sweetbread.unn.feature.About
|
||||
import ru.sweetbread.unn.feature.news.Blogposts
|
||||
import ru.sweetbread.unn.feature.schedule.Schedule
|
||||
import ru.sweetbread.unn.ui.theme.UNNTheme
|
||||
import splitties.resources.appStr
|
||||
import splitties.toast.toast
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
private var restoreSessionJob: Job? = null
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
restoreSavedSession()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
val authRepo = (application as UNNApp).container.authRepo
|
||||
lifecycleScope.launch {
|
||||
authRepo.authenticationFailures.collect {
|
||||
openLogin()
|
||||
}
|
||||
}
|
||||
connectivityManager = getSystemService(ConnectivityManager::class.java)
|
||||
restoreSavedSession()
|
||||
|
||||
setContent {
|
||||
UNNTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
DrawerContent(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(.75f)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.systemBarsPadding(),
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
when {
|
||||
currentRoute?.startsWith("portal/") == true ->
|
||||
appStr(R.string.news)
|
||||
|
||||
currentRoute?.startsWith("journal/") == true ->
|
||||
appStr(R.string.schedule)
|
||||
|
||||
else -> appStr(R.string.app_name)
|
||||
},
|
||||
Modifier.padding()
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch { drawerState.open() }
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Filled.Menu, "Меню")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
onClick = {
|
||||
navController.navigate("portal/blogposts") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.Home,
|
||||
contentDescription = "Home"
|
||||
)
|
||||
},
|
||||
selected = currentRoute?.startsWith("portal/") == true
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
onClick = {
|
||||
navController.navigate("journal/schedule") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.DateRange,
|
||||
contentDescription = "Schedule"
|
||||
)
|
||||
},
|
||||
selected = currentRoute?.startsWith("journal/") == true
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
onClick = { toast("Not implemented") },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.AccountBox,
|
||||
contentDescription = "Account"
|
||||
)
|
||||
},
|
||||
selected = false
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(Modifier.padding(innerPadding)) {
|
||||
NavHost(navController, startDestination = "journal/schedule") {
|
||||
composable("portal/blogposts") {
|
||||
Blogposts()
|
||||
}
|
||||
composable("journal/schedule") {
|
||||
Schedule()
|
||||
}
|
||||
composable("about") {
|
||||
About()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun restoreSavedSession() {
|
||||
val container = (application as UNNApp).container
|
||||
|
||||
if (
|
||||
container.sessionManager.sessionId != null ||
|
||||
restoreSessionJob?.isActive == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
restoreSessionJob = lifecycleScope.launch {
|
||||
if (container.authRepo.restoreSession() == LoginResult.InvalidCredentials) {
|
||||
openLogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLogin() {
|
||||
if (isFinishing) return
|
||||
|
||||
startActivity(
|
||||
Intent(this, LoginActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerContent(modifier: Modifier = Modifier, navController: NavController) {
|
||||
val me = (application as UNNApp).container.sessionManager.user.collectAsState().value
|
||||
|
||||
if (me != null) {
|
||||
Column(modifier = modifier.padding(0.dp)) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AsyncImage(
|
||||
modifier = Modifier.size(64.dp).clip(CircleShape),
|
||||
model = portalURL + me.avatar.thumbnail,
|
||||
contentDescription = me.nameEn
|
||||
)
|
||||
|
||||
Column(Modifier.padding(horizontal = 8.dp)) {
|
||||
Text(
|
||||
me.nameRu.split(" ").dropLast(1).joinToString(" "),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(me.type.s)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
|
||||
TextButton(
|
||||
{},
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RectangleShape,
|
||||
enabled = false
|
||||
) {
|
||||
Text(
|
||||
appStr(R.string.record_book),
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
{},
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RectangleShape,
|
||||
enabled = false
|
||||
) {
|
||||
Text(
|
||||
appStr(R.string.documents),
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
{},
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RectangleShape,
|
||||
enabled = false
|
||||
) {
|
||||
Text(
|
||||
appStr(R.string.materials),
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
HorizontalDivider()
|
||||
TextButton(
|
||||
{ navController.navigate("about") },
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RectangleShape
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = "Info",
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
Text(appStr(R.string.about_app))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("User not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import ru.sweetbread.unn.core.AuthRepository
|
||||
import ru.sweetbread.unn.core.CredentialsStore
|
||||
import ru.sweetbread.unn.core.SessionManager
|
||||
import ru.sweetbread.unn.core.db.AppDatabase
|
||||
import ru.sweetbread.unn.core.network.LoginResult
|
||||
import ru.sweetbread.unn.core.network.PortalApi
|
||||
import ru.sweetbread.unn.core.network.SessionRefreshHandler
|
||||
import ru.sweetbread.unn.core.network.createHttpClient
|
||||
import ru.sweetbread.unn.feature.news.NewsRepository
|
||||
import ru.sweetbread.unn.feature.profile.UserRepository
|
||||
import ru.sweetbread.unn.feature.schedule.ScheduleRepository
|
||||
import splitties.arch.room.roomDb
|
||||
|
||||
class UNNApp : Application() {
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
container = AppContainer(this)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base)
|
||||
}
|
||||
}
|
||||
|
||||
class AppContainer(context: Context) {
|
||||
val credentialsStore = CredentialsStore(context)
|
||||
val sessionManager = SessionManager()
|
||||
val sessionRefreshHandler = SessionRefreshHandler()
|
||||
|
||||
val httpClient = createHttpClient(sessionRefreshHandler)
|
||||
|
||||
val portalApi = PortalApi(
|
||||
client = httpClient,
|
||||
session = sessionManager
|
||||
)
|
||||
|
||||
val authRepo = AuthRepository(credentialsStore, portalApi)
|
||||
|
||||
init {
|
||||
sessionRefreshHandler.setRefreshAction {
|
||||
authRepo.restoreSession() == LoginResult.Success
|
||||
}
|
||||
}
|
||||
|
||||
val database = roomDb<AppDatabase>(name = "cache") {
|
||||
fallbackToDestructiveMigration(dropAllTables = true)
|
||||
}
|
||||
|
||||
val userRepository = UserRepository(
|
||||
portalApi,
|
||||
database,
|
||||
)
|
||||
val newsRepository = NewsRepository(
|
||||
portalApi,
|
||||
database,
|
||||
sessionManager,
|
||||
userRepository
|
||||
)
|
||||
val scheduleRepository = ScheduleRepository(
|
||||
api = portalApi,
|
||||
db = database,
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import ru.sweetbread.unn.core.network.LoginResult
|
||||
import ru.sweetbread.unn.core.network.PortalApi
|
||||
|
||||
class AuthRepository(
|
||||
private val credentialsStore: CredentialsStore,
|
||||
private val api: PortalApi
|
||||
) {
|
||||
private val restoreMutex = Mutex()
|
||||
private val _authenticationFailures = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val authenticationFailures = _authenticationFailures.asSharedFlow()
|
||||
|
||||
suspend fun login(
|
||||
login: String,
|
||||
password: String
|
||||
): Boolean {
|
||||
val res = api.login(login, password) == LoginResult.Success
|
||||
|
||||
if (res) {
|
||||
credentialsStore.save(
|
||||
Credentials(login, password)
|
||||
)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
fun hasSavedCredentials(): Boolean = credentialsStore.get() != null
|
||||
|
||||
suspend fun restoreSession(): LoginResult? = restoreMutex.withLock {
|
||||
val credentials = credentialsStore.get() ?: return@withLock null
|
||||
|
||||
val result = api.login(
|
||||
credentials.login,
|
||||
credentials.password
|
||||
)
|
||||
|
||||
if (result == LoginResult.InvalidCredentials) {
|
||||
credentialsStore.clear()
|
||||
api.session.clear()
|
||||
_authenticationFailures.emit(Unit)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import androidx.core.content.edit
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
|
||||
data class Credentials(
|
||||
val login: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
class CredentialsStore(
|
||||
context: Context
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(
|
||||
"credentials",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
private val keyAlias = "credentials_key"
|
||||
|
||||
private val key: SecretKey
|
||||
get() {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
|
||||
load(null)
|
||||
}
|
||||
|
||||
val existingKey = keyStore.getKey(
|
||||
keyAlias,
|
||||
null
|
||||
) as? SecretKey
|
||||
|
||||
if (existingKey != null)
|
||||
return existingKey
|
||||
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
"AndroidKeyStore"
|
||||
)
|
||||
|
||||
keyGenerator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or
|
||||
KeyProperties.PURPOSE_DECRYPT
|
||||
).setBlockModes(
|
||||
KeyProperties.BLOCK_MODE_GCM
|
||||
).setEncryptionPaddings(
|
||||
KeyProperties.ENCRYPTION_PADDING_NONE
|
||||
).setKeySize(256)
|
||||
.build()
|
||||
)
|
||||
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
|
||||
fun save(credentials: Credentials) {
|
||||
val plainText =
|
||||
"${credentials.login}\u0000${credentials.password}"
|
||||
.encodeToByteArray()
|
||||
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
|
||||
cipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
key
|
||||
)
|
||||
|
||||
val encrypted = cipher.doFinal(plainText)
|
||||
|
||||
prefs.edit {
|
||||
putString(
|
||||
"data",
|
||||
Base64.encodeToString(
|
||||
encrypted,
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
)
|
||||
putString(
|
||||
"iv",
|
||||
Base64.encodeToString(
|
||||
cipher.iv,
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): Credentials? {
|
||||
return try {
|
||||
val encryptedString =
|
||||
prefs.getString("data", null)
|
||||
?: return null
|
||||
|
||||
val ivString =
|
||||
prefs.getString("iv", null)
|
||||
?: return null
|
||||
|
||||
val encrypted = Base64.decode(
|
||||
encryptedString,
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
|
||||
val iv = Base64.decode(
|
||||
ivString,
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
key,
|
||||
GCMParameterSpec(128, iv)
|
||||
)
|
||||
|
||||
val plainText = cipher
|
||||
.doFinal(encrypted)
|
||||
.decodeToString()
|
||||
|
||||
val parts = plainText.split('\u0000', limit = 2)
|
||||
|
||||
if (parts.size != 2)
|
||||
null
|
||||
else Credentials(
|
||||
login = parts[0],
|
||||
password = parts[1]
|
||||
)
|
||||
} catch (_: GeneralSecurityException) {
|
||||
clear()
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
clear()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit { clear() }
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import ru.sweetbread.unn.model.User
|
||||
|
||||
class SessionManager {
|
||||
private val _user = MutableStateFlow<User?>(null)
|
||||
val user = _user.asStateFlow()
|
||||
|
||||
var sessionId: String? = null
|
||||
private set
|
||||
|
||||
var uns: Int? = null
|
||||
private set
|
||||
|
||||
fun update(
|
||||
user: User,
|
||||
sessionId: String,
|
||||
uns: Int
|
||||
) {
|
||||
this._user.value = user
|
||||
this.sessionId = sessionId
|
||||
this.uns = uns
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_user.value = null
|
||||
sessionId = null
|
||||
uns = null
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import ru.sweetbread.unn.core.db.dao.AuditoriumDao
|
||||
import ru.sweetbread.unn.core.db.dao.BuildingDao
|
||||
import ru.sweetbread.unn.core.db.dao.DisciplineDao
|
||||
import ru.sweetbread.unn.core.db.dao.KindOfWorkDao
|
||||
import ru.sweetbread.unn.core.db.dao.LecturerDao
|
||||
import ru.sweetbread.unn.core.db.dao.NewsDao
|
||||
import ru.sweetbread.unn.core.db.dao.ScheduleItemDao
|
||||
import ru.sweetbread.unn.core.db.dao.UserDao
|
||||
import ru.sweetbread.unn.core.db.entity.AuditoriumDB
|
||||
import ru.sweetbread.unn.core.db.entity.BuildingDB
|
||||
import ru.sweetbread.unn.core.db.entity.DisciplineDB
|
||||
import ru.sweetbread.unn.core.db.entity.KindOfWorkDB
|
||||
import ru.sweetbread.unn.core.db.entity.LecturerDB
|
||||
import ru.sweetbread.unn.core.db.entity.NewsDB
|
||||
import ru.sweetbread.unn.core.db.entity.ScheduleUnitDB
|
||||
import ru.sweetbread.unn.core.db.entity.UserDB
|
||||
|
||||
@Database(entities = [
|
||||
UserDB::class,
|
||||
BuildingDB::class,
|
||||
AuditoriumDB::class,
|
||||
DisciplineDB::class,
|
||||
KindOfWorkDB::class,
|
||||
LecturerDB::class,
|
||||
ScheduleUnitDB::class,
|
||||
NewsDB::class
|
||||
], version = 3)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun userDao(): UserDao
|
||||
abstract fun scheduleDao(): ScheduleItemDao
|
||||
abstract fun auditoriumDao(): AuditoriumDao
|
||||
abstract fun buildingDao(): BuildingDao
|
||||
abstract fun disciplineDao(): DisciplineDao
|
||||
abstract fun kindOfWorkDao(): KindOfWorkDao
|
||||
abstract fun lecturerDao(): LecturerDao
|
||||
abstract fun newsDao(): NewsDao
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import ru.sweetbread.unn.core.db.entity.AuditoriumDB
|
||||
|
||||
@Dao
|
||||
interface AuditoriumDao {
|
||||
@Query("SELECT * FROM auditoriumdb WHERE oid = :oid LIMIT 1")
|
||||
suspend fun get(oid: Int): AuditoriumDB?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(auditoriums: List<AuditoriumDB>)
|
||||
|
||||
@Delete
|
||||
fun delete(auditorium: AuditoriumDB)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import ru.sweetbread.unn.core.db.entity.BuildingDB
|
||||
|
||||
@Dao
|
||||
interface BuildingDao {
|
||||
@Query("SELECT * FROM buildingdb WHERE oid = :oid LIMIT 1")
|
||||
suspend fun get(oid: Int): BuildingDB?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(buildings: List<BuildingDB>)
|
||||
|
||||
@Delete
|
||||
fun delete(building: BuildingDB)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import ru.sweetbread.unn.core.db.entity.DisciplineDB
|
||||
|
||||
@Dao
|
||||
interface DisciplineDao {
|
||||
@Query("SELECT * FROM disciplinedb WHERE oid = :oid LIMIT 1")
|
||||
suspend fun get(oid: Int): DisciplineDB?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(disciplines: List<DisciplineDB>)
|
||||
|
||||
@Delete
|
||||
fun delete(discipline: DisciplineDB)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import ru.sweetbread.unn.core.db.entity.KindOfWorkDB
|
||||
|
||||
@Dao
|
||||
interface KindOfWorkDao {
|
||||
@Query("SELECT * FROM kindofworkdb WHERE oid = :oid LIMIT 1")
|
||||
suspend fun get(oid: Int): KindOfWorkDB?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(kindOfWork: List<KindOfWorkDB>)
|
||||
|
||||
@Delete
|
||||
fun delete(kindOfWork: KindOfWorkDB)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import ru.sweetbread.unn.core.db.entity.LecturerDB
|
||||
|
||||
@Dao
|
||||
interface LecturerDao {
|
||||
@Query("SELECT * FROM lecturerdb WHERE unnId = :unnId LIMIT 1")
|
||||
suspend fun get(unnId: Int): LecturerDB?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(lecturers: List<LecturerDB>)
|
||||
|
||||
@Delete
|
||||
fun delete(lecturer: LecturerDB)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.dao
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.sweetbread.unn.core.db.entity.NewsDB
|
||||
|
||||
@Dao
|
||||
interface NewsDao {
|
||||
@Query("SELECT * FROM newsDB WHERE id = :id LIMIT 1")
|
||||
suspend fun get(id: Int): NewsDB?
|
||||
|
||||
@Query("SELECT * FROM newsDB ORDER BY `publishedAt` DESC")
|
||||
fun getFlow(): Flow<List<NewsDB>>
|
||||
|
||||
@Query("SELECT * FROM newsDB ORDER BY `publishedAt` DESC")
|
||||
fun paging(): PagingSource<Int, NewsDB>
|
||||
|
||||
@Query("DELETE FROM newsDB")
|
||||
suspend fun clear()
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(news: List<NewsDB>)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(unit: NewsDB)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.sweetbread.unn.core.db.entity.ScheduleUnitDB
|
||||
|
||||
@Dao
|
||||
interface ScheduleItemDao {
|
||||
@Query("SELECT * FROM scheduleUnitDB WHERE oid = :oid LIMIT 1")
|
||||
suspend fun getSchedule(oid: Int): ScheduleUnitDB?
|
||||
|
||||
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
|
||||
suspend fun getSchedule(date: String): List<ScheduleUnitDB>
|
||||
|
||||
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
|
||||
fun getScheduleFlow(date: String): Flow<List<ScheduleUnitDB>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(units: List<ScheduleUnitDB>)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(unit: ScheduleUnitDB)
|
||||
|
||||
@Query("DELETE FROM scheduleUnitDB WHERE date = :date")
|
||||
suspend fun deleteByDate(date: String)
|
||||
|
||||
@Query("SELECT * FROM scheduleUnitDB WHERE date = :date ORDER BY `begin`")
|
||||
fun observe(date: String): Flow<List<ScheduleUnitDB>>
|
||||
|
||||
@Query("SELECT * FROM scheduleUnitDB")
|
||||
suspend fun getAll(): List<ScheduleUnitDB>
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import ru.sweetbread.unn.core.db.entity.UserDB
|
||||
|
||||
@Dao
|
||||
interface UserDao {
|
||||
@Query("SELECT * FROM userDB WHERE userId = :userId LIMIT 1")
|
||||
suspend fun get(userId: Int): UserDB?
|
||||
|
||||
@Insert
|
||||
suspend fun insert(user: UserDB)
|
||||
|
||||
@Delete
|
||||
fun delete(user: UserDB)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.withTransaction
|
||||
import ru.sweetbread.unn.core.db.AppDatabase
|
||||
import ru.sweetbread.unn.model.Auditorium
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Entity
|
||||
data class AuditoriumDB(
|
||||
@PrimaryKey val oid: Int,
|
||||
@ColumnInfo val name: String,
|
||||
@ColumnInfo val floor: Int?,
|
||||
@ColumnInfo val buildingOid: Int,
|
||||
@ColumnInfo val updatedAt: String
|
||||
)
|
||||
|
||||
|
||||
suspend fun AuditoriumDB.toModel(db: AppDatabase): Auditorium? {
|
||||
return Auditorium(
|
||||
name,
|
||||
oid,
|
||||
floor ?: 0,
|
||||
db.buildingDao().get(buildingOid)?.toModel() ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
fun Auditorium.toEntity() = AuditoriumDB(
|
||||
oid,
|
||||
name,
|
||||
floor,
|
||||
building.oid,
|
||||
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
)
|
||||
|
||||
suspend fun cacheAuditorium(db: AppDatabase, auditoriums: List<Auditorium>) {
|
||||
db.withTransaction {
|
||||
db.buildingDao().insert(
|
||||
auditoriums.map { it.building }.map { it.toEntity() }
|
||||
)
|
||||
|
||||
db.auditoriumDao().insert(
|
||||
auditoriums.map { it.toEntity() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import ru.sweetbread.unn.model.Building
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Entity
|
||||
data class BuildingDB(
|
||||
@PrimaryKey val oid: Int,
|
||||
@ColumnInfo val name: String,
|
||||
@ColumnInfo val gid: Int,
|
||||
@ColumnInfo val updatedAt: String
|
||||
)
|
||||
|
||||
fun BuildingDB.toModel() = Building(name, gid, oid)
|
||||
fun Building.toEntity() = BuildingDB(oid, name, gid, LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME))
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import ru.sweetbread.unn.model.Discipline
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Entity
|
||||
data class DisciplineDB(
|
||||
@PrimaryKey val oid: Int,
|
||||
@ColumnInfo val name: String,
|
||||
@ColumnInfo val type: Int,
|
||||
@ColumnInfo val updatedAt: String
|
||||
)
|
||||
|
||||
fun DisciplineDB.toModel() = Discipline(name, oid, type)
|
||||
fun Discipline.toEntity() = DisciplineDB(oid, name, type, LocalDateTime.now().format(
|
||||
DateTimeFormatter.ISO_DATE_TIME))
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import ru.sweetbread.unn.model.KindOfWork
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Entity
|
||||
data class KindOfWorkDB(
|
||||
@PrimaryKey val oid: Int,
|
||||
@ColumnInfo val name: String,
|
||||
@ColumnInfo val uid: String,
|
||||
@ColumnInfo val complexity: Int,
|
||||
@ColumnInfo val updatedAt: String
|
||||
)
|
||||
|
||||
fun KindOfWorkDB.toModel() = KindOfWork(name, oid, uid, complexity)
|
||||
fun KindOfWork.toEntity() = KindOfWorkDB(
|
||||
oid,
|
||||
name,
|
||||
uid,
|
||||
complexity,
|
||||
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import ru.sweetbread.unn.model.Lecturer
|
||||
import ru.sweetbread.unn.model.LecturerRank
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Entity
|
||||
data class LecturerDB(
|
||||
@PrimaryKey val unnId: Int,
|
||||
@ColumnInfo val name: String,
|
||||
@ColumnInfo val rank: LecturerRank,
|
||||
@ColumnInfo val email: String,
|
||||
@ColumnInfo val uid: String,
|
||||
@ColumnInfo val updatedAt: String
|
||||
)
|
||||
|
||||
fun LecturerDB.toModel() = Lecturer(name, rank, email, unnId, uid)
|
||||
fun Lecturer.toEntity() = LecturerDB(
|
||||
unnId,
|
||||
name,
|
||||
rank,
|
||||
email,
|
||||
uid,
|
||||
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import ru.sweetbread.unn.feature.profile.UserRepository
|
||||
import ru.sweetbread.unn.model.News
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Entity
|
||||
data class NewsDB(
|
||||
@PrimaryKey val id: Int,
|
||||
@ColumnInfo val publishedAt: String,
|
||||
@ColumnInfo val content: String,
|
||||
@ColumnInfo val authorId: Int
|
||||
)
|
||||
|
||||
|
||||
suspend fun NewsDB.toModel(userRepo: UserRepository): News = News(
|
||||
id,
|
||||
LocalDateTime.parse(publishedAt, DateTimeFormatter.ISO_DATE_TIME),
|
||||
content,
|
||||
userRepo.getByUserId(this.authorId),
|
||||
)
|
||||
|
||||
fun News.toEntity(): NewsDB = NewsDB(
|
||||
id,
|
||||
publishedAt.format(DateTimeFormatter.ISO_DATE_TIME),
|
||||
content,
|
||||
author.userId
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.withTransaction
|
||||
import ru.sweetbread.unn.core.db.AppDatabase
|
||||
import ru.sweetbread.unn.model.ScheduleUnit
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Entity
|
||||
data class ScheduleUnitDB(
|
||||
@PrimaryKey val oid: Int,
|
||||
@ColumnInfo val date: String,
|
||||
@ColumnInfo val stream: String,
|
||||
@ColumnInfo val begin: String,
|
||||
@ColumnInfo val end: String,
|
||||
|
||||
@ColumnInfo val auditoriumOid: Int,
|
||||
@ColumnInfo val disciplineOid: Int,
|
||||
@ColumnInfo val kindOfWorkOid: Int,
|
||||
@ColumnInfo val lecturersId: String,
|
||||
|
||||
@ColumnInfo val updatedAt: String
|
||||
)
|
||||
|
||||
|
||||
suspend fun ScheduleUnitDB.toModel(db: AppDatabase): ScheduleUnit? {
|
||||
return ScheduleUnit(
|
||||
oid,
|
||||
db.auditoriumDao().get(auditoriumOid)?.toModel(db) ?: return null,
|
||||
LocalDate.parse(date, DateTimeFormatter.ISO_DATE),
|
||||
db.disciplineDao().get(disciplineOid)?.toModel() ?: return null,
|
||||
db.kindOfWorkDao().get(kindOfWorkOid)?.toModel() ?: return null,
|
||||
lecturersId.split(",").map { db.lecturerDao().get(it.toInt())?.toModel() ?: return null },
|
||||
stream,
|
||||
LocalTime.parse(begin, DateTimeFormatter.ISO_TIME),
|
||||
LocalTime.parse(end, DateTimeFormatter.ISO_TIME),
|
||||
LocalDateTime.parse(updatedAt, DateTimeFormatter.ISO_DATE_TIME),
|
||||
)
|
||||
}
|
||||
|
||||
fun ScheduleUnit.toEntity() = ScheduleUnitDB(
|
||||
oid,
|
||||
date.format(DateTimeFormatter.ISO_DATE),
|
||||
stream,
|
||||
begin.format(DateTimeFormatter.ISO_TIME),
|
||||
end.format(DateTimeFormatter.ISO_TIME),
|
||||
auditorium.oid,
|
||||
discipline.oid,
|
||||
kindOfWork.oid,
|
||||
lecturers.map { it.unnId }.joinToString(","),
|
||||
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
)
|
||||
|
||||
|
||||
suspend fun cacheSchedule(db: AppDatabase, units: List<ScheduleUnit>) {
|
||||
db.withTransaction {
|
||||
cacheAuditorium(db, units.map { it.auditorium })
|
||||
db.disciplineDao().insert(units.map { it.discipline.toEntity() })
|
||||
db.kindOfWorkDao().insert(units.map { it.kindOfWork.toEntity() })
|
||||
db.lecturerDao().insert(units.flatMap { it.lecturers }.map { it.toEntity() })
|
||||
db.scheduleDao().insert(units.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import io.ktor.util.reflect.instanceOf
|
||||
import ru.sweetbread.unn.model.AvatarSet
|
||||
import ru.sweetbread.unn.model.Student
|
||||
import ru.sweetbread.unn.model.Type
|
||||
import ru.sweetbread.unn.model.User
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Entity
|
||||
data class UserDB(
|
||||
@PrimaryKey val userId: Int,
|
||||
@ColumnInfo val uns: Int?,
|
||||
@ColumnInfo val group: String?,
|
||||
@ColumnInfo val extId: Int?,
|
||||
@ColumnInfo val type: Type,
|
||||
@ColumnInfo val email: String,
|
||||
@ColumnInfo val nameRu: String,
|
||||
@ColumnInfo val nameEn: String,
|
||||
@ColumnInfo val origAvatar: String,
|
||||
@ColumnInfo val thumbAvatar: String,
|
||||
@ColumnInfo val smallAvatar: String,
|
||||
@ColumnInfo val updatedAt: String
|
||||
)
|
||||
|
||||
|
||||
fun User.toEntity(): UserDB = UserDB(
|
||||
userId,
|
||||
if (this.instanceOf(Student::class)) (this as Student).uns else null,
|
||||
if (this.instanceOf(Student::class)) (this as Student).group else null,
|
||||
null,
|
||||
type,
|
||||
email,
|
||||
nameRu,
|
||||
nameEn,
|
||||
avatar.original,
|
||||
avatar.thumbnail,
|
||||
avatar.small,
|
||||
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
)
|
||||
|
||||
fun UserDB.toModel(): User {
|
||||
val user = User(
|
||||
userId,
|
||||
type,
|
||||
email,
|
||||
nameRu,
|
||||
nameEn,
|
||||
AvatarSet(
|
||||
origAvatar,
|
||||
thumbAvatar,
|
||||
smallAvatar
|
||||
)
|
||||
)
|
||||
|
||||
// if (type == Type.student)
|
||||
// user = Student(uns!!, user, group!!)
|
||||
|
||||
return user
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.io.IOException
|
||||
import ru.sweetbread.unn.core.SessionManager
|
||||
|
||||
|
||||
class PortalApi (
|
||||
val client: HttpClient,
|
||||
val session: SessionManager
|
||||
) {
|
||||
public val auth = Auth(client)
|
||||
public val ruzapi = Ruzapi(client, session)
|
||||
public val bitrix = Bitrix(client)
|
||||
|
||||
/**
|
||||
* Authorize user by [login] and [password]
|
||||
*/
|
||||
suspend fun login(
|
||||
login: String,
|
||||
password: String
|
||||
): LoginResult {
|
||||
return try {
|
||||
val id = auth.getToken(login, password)
|
||||
|
||||
if (id == null) {
|
||||
LoginResult.InvalidCredentials
|
||||
} else {
|
||||
session.update(
|
||||
user = bitrix.getCurrentUser(),
|
||||
sessionId = id,
|
||||
uns = login.drop(1).toInt()
|
||||
)
|
||||
|
||||
LoginResult.Success
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
LoginResult.NetworkError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface LoginResult {
|
||||
data object Success : LoginResult
|
||||
data object InvalidCredentials : LoginResult
|
||||
data object NetworkError : LoginResult
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.network
|
||||
|
||||
import android.util.Log
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.HttpSend
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.cache.HttpCache
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.plugin
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.util.AttributeKey
|
||||
|
||||
private val SkipSessionRefresh = AttributeKey<Unit>("SkipSessionRefresh")
|
||||
private val SessionRefreshAttempted = AttributeKey<Unit>("SessionRefreshAttempted")
|
||||
|
||||
fun HttpRequestBuilder.skipSessionRefresh() {
|
||||
attributes.put(SkipSessionRefresh, Unit)
|
||||
}
|
||||
|
||||
class SessionRefreshHandler {
|
||||
@Volatile
|
||||
private var refresh: suspend () -> Boolean = { false }
|
||||
|
||||
fun setRefreshAction(action: suspend () -> Boolean) {
|
||||
refresh = action
|
||||
}
|
||||
|
||||
suspend fun refreshSession(): Boolean = refresh()
|
||||
}
|
||||
|
||||
fun createHttpClient(sessionRefreshHandler: SessionRefreshHandler) = HttpClient(Android) {
|
||||
install(HttpCache)
|
||||
install(HttpCookies)
|
||||
install(Logging) {
|
||||
logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
Log.i("Ktor", message)
|
||||
}
|
||||
}
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
connectTimeoutMillis = 5000
|
||||
}
|
||||
install(HttpRequestRetry) {
|
||||
retryOnException(maxRetries = 3, retryOnTimeout = true)
|
||||
exponentialDelay()
|
||||
modifyRequest { request ->
|
||||
request.headers.append("x-retry-count", retryCount.toString())
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
plugin(HttpSend).intercept { request ->
|
||||
val call = execute(request)
|
||||
|
||||
if (
|
||||
call.response.status == HttpStatusCode.Forbidden &&
|
||||
request.attributes.getOrNull(SkipSessionRefresh) == null &&
|
||||
request.attributes.getOrNull(SessionRefreshAttempted) == null
|
||||
) {
|
||||
request.attributes.put(SessionRefreshAttempted, Unit)
|
||||
|
||||
if (sessionRefreshHandler.refreshSession()) {
|
||||
execute(request)
|
||||
} else {
|
||||
call
|
||||
}
|
||||
} else {
|
||||
call
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.core.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.retry
|
||||
import io.ktor.client.request.forms.submitForm
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.parameters
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.sweetbread.unn.core.SessionManager
|
||||
import ru.sweetbread.unn.model.Auditorium
|
||||
import ru.sweetbread.unn.model.Building
|
||||
import ru.sweetbread.unn.model.Discipline
|
||||
import ru.sweetbread.unn.model.KindOfWork
|
||||
import ru.sweetbread.unn.model.Lecturer
|
||||
import ru.sweetbread.unn.model.LecturerRank
|
||||
import ru.sweetbread.unn.model.NewsDto
|
||||
import ru.sweetbread.unn.model.ScheduleUnit
|
||||
import ru.sweetbread.unn.model.User
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale.getDefault
|
||||
|
||||
|
||||
const val portalURL = "https://portal.unn.ru"
|
||||
const val ruzapiURL = "$portalURL/ruzapi"
|
||||
const val vuzapiURL = "$portalURL/bitrix/vuz/api"
|
||||
const val prtl2URL = "$portalURL/portal2/api"
|
||||
const val restURL = "$portalURL/rest"
|
||||
|
||||
|
||||
class Auth (
|
||||
val client: HttpClient,
|
||||
) {
|
||||
/**
|
||||
* Get token (phpsessid header) from portal
|
||||
*/
|
||||
suspend fun getToken(login: String, password: String): String? {
|
||||
val r = client.submitForm("$portalURL/auth/?login=yes",
|
||||
formParameters = parameters {
|
||||
append("AUTH_FORM", "Y")
|
||||
append("TYPE", "AUTH")
|
||||
append("backurl", "/")
|
||||
append("USER_LOGIN", login)
|
||||
append("USER_PASSWORD", password)
|
||||
}
|
||||
) {
|
||||
retry { noRetry() }
|
||||
skipSessionRefresh()
|
||||
}
|
||||
|
||||
if (r.status.value == 302)
|
||||
return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun getCSRF(): String {
|
||||
val r = client.get("$restURL/log.blogpost.get") {
|
||||
parameter("sessid", "")
|
||||
}
|
||||
return JSONObject(r.bodyAsText()).getString("sessid")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Bitrix addon for universities.
|
||||
* Here are schedules, students and lecturers, auditorium and building
|
||||
*
|
||||
* РУЗ - расписание учебных занятий
|
||||
*/
|
||||
class Ruzapi(
|
||||
val client: HttpClient,
|
||||
val session: SessionManager
|
||||
) {
|
||||
val unnDatePattern: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
enum class Type {Group, Student, Person, Lecturer, Auditorium}
|
||||
|
||||
suspend fun search(term: String, type: Type? = null): JSONArray {
|
||||
val r = client.get("$ruzapiURL/search") {
|
||||
parameter("term", term)
|
||||
type?.let { parameter("type", type.name.lowercase(getDefault())) }
|
||||
}
|
||||
|
||||
return JSONArray(r)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schedule of a student by login. By default, your own
|
||||
*/
|
||||
suspend fun getScheduleByUns(
|
||||
uns: Int = session.uns!!,
|
||||
from: LocalDate = LocalDate.now(),
|
||||
until: LocalDate = LocalDate.now()
|
||||
): ArrayList<ScheduleUnit> {
|
||||
// TODO: Check for type = student before using
|
||||
// _______
|
||||
val r = client.get("$ruzapiURL/schedule/student") {
|
||||
parameter("uns", uns)
|
||||
parameter("start", from.format(unnDatePattern))
|
||||
parameter("finish", until.format(unnDatePattern))
|
||||
}
|
||||
|
||||
return parseSchedule(r.bodyAsText())
|
||||
}
|
||||
|
||||
private fun parseSchedule(body: String): ArrayList<ScheduleUnit> {
|
||||
val json = JSONArray(body)
|
||||
val out = arrayListOf<ScheduleUnit>()
|
||||
for (i in 0 until json.length()) {
|
||||
val unit = json.getJSONObject(i)
|
||||
val lecturesJson = unit.getJSONArray("listOfLecturers")
|
||||
val lecturers = arrayListOf<Lecturer>()
|
||||
|
||||
for (j in 0 until lecturesJson.length()) {
|
||||
val lecturer = lecturesJson.getJSONObject(j)
|
||||
lecturers.add(
|
||||
Lecturer(
|
||||
name = lecturer.getString("lecturer"),
|
||||
email = lecturer.getString("lecturerEmail"),
|
||||
unnId = lecturer.getInt("lecturerOid"),
|
||||
uid = lecturer.getString("lecturerUID"),
|
||||
rank = when (lecturer.getString("lecturer_rank")) {
|
||||
"АССИСТ" -> LecturerRank.Assistant
|
||||
"СТПРЕП" -> LecturerRank.SLecturer
|
||||
"ДОЦЕНТ" -> LecturerRank.AProfessor
|
||||
else -> LecturerRank.Lecturer
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
out.add(
|
||||
ScheduleUnit(
|
||||
oid = unit.getInt("lessonOid"),
|
||||
auditorium = Auditorium(
|
||||
name = unit.getString("auditorium"),
|
||||
oid = unit.getInt("auditoriumOid"),
|
||||
floor = unit.getInt("auditoriumfloor"),
|
||||
building = Building(
|
||||
name = unit.getString("building"),
|
||||
gid = unit.getInt("buildingGid"),
|
||||
oid = unit.getInt("buildingOid")
|
||||
)
|
||||
),
|
||||
date = LocalDate.parse(unit.getString("date"), unnDatePattern),
|
||||
discipline = Discipline(
|
||||
name = unit.getString("discipline"),
|
||||
oid = unit.getInt("disciplineOid"),
|
||||
type = unit.getInt("disciplinetypeload")
|
||||
),
|
||||
kindOfWork = KindOfWork(
|
||||
name = unit.getString("kindOfWork"),
|
||||
complexity = unit.getInt("kindOfWorkComplexity"),
|
||||
oid = unit.getInt("kindOfWorkOid"),
|
||||
uid = unit.getString("kindOfWorkUid")
|
||||
),
|
||||
lecturers = lecturers,
|
||||
stream = unit.getString("stream"),
|
||||
begin = LocalTime.parse(
|
||||
unit.getString("beginLesson"),
|
||||
DateTimeFormatter.ofPattern("HH:mm")
|
||||
),
|
||||
end = LocalTime.parse(
|
||||
unit.getString("endLesson"),
|
||||
DateTimeFormatter.ofPattern("HH:mm")
|
||||
),
|
||||
LocalDateTime.now()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Bitrix(
|
||||
val client: HttpClient,
|
||||
) {
|
||||
suspend fun getCurrentUser(): User {
|
||||
val r = client.get("$vuzapiURL/profile/current") {
|
||||
skipSessionRefresh()
|
||||
}
|
||||
return User.fromProfile(JSONObject(r.bodyAsText()))
|
||||
}
|
||||
|
||||
suspend fun getUserByID(userId: Int): User {
|
||||
val r = client.get("$vuzapiURL/user/$userId")
|
||||
return User(JSONObject(r.bodyAsText()))
|
||||
}
|
||||
|
||||
suspend fun getNews(page: Int = 1, amount: Int = 20): ArrayList<NewsDto> {
|
||||
val r = client.get("$prtl2URL/news_.php") {
|
||||
parameter("numpage", page)
|
||||
parameter("perpage", amount)
|
||||
}
|
||||
return NewsDto.fromJsonArray(JSONArray(r.bodyAsText()))
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.sweetbread.unn.R
|
||||
import splitties.resources.appStr
|
||||
|
||||
@Composable
|
||||
fun About() {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
appStr(R.string.developer),
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text("meow")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.news
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.Html
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.util.Linkify
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import ru.sweetbread.unn.core.network.portalURL
|
||||
import ru.sweetbread.unn.model.News
|
||||
import ru.sweetbread.unn.model.User
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
|
||||
@Composable
|
||||
fun Blogposts(viewModel: NewsViewModel = viewModel(factory = NewsViewModel.Factory)) {
|
||||
val news = viewModel.news.collectAsLazyPagingItems()
|
||||
var extended by remember { mutableIntStateOf(0) }
|
||||
|
||||
LazyColumn {
|
||||
items(
|
||||
count = news.itemCount,
|
||||
key = news.itemKey { it.id }
|
||||
) {
|
||||
news[it]?.let { post ->
|
||||
PostItem(
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable {
|
||||
extended = if (extended == post.id) 0 else post.id
|
||||
},
|
||||
post = post,
|
||||
extended == post.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserItem(modifier: Modifier = Modifier, user: User, info: String? = null) {
|
||||
Row(
|
||||
modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(50)),
|
||||
model = portalURL + user.avatar.thumbnail,
|
||||
contentDescription = user.nameEn
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(user.nameRu, fontWeight = FontWeight.Bold)
|
||||
if (!info.isNullOrBlank())
|
||||
Text(
|
||||
text = info,
|
||||
fontStyle = FontStyle.Italic,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PostItem(modifier: Modifier = Modifier, post: News, extended: Boolean = false) {
|
||||
var html: String by remember { mutableStateOf("") }
|
||||
|
||||
|
||||
LaunchedEffect(post) {
|
||||
html = post.content
|
||||
}
|
||||
|
||||
Column(modifier.padding(16.dp)) {
|
||||
UserItem(user = post.author)
|
||||
|
||||
val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
|
||||
val linkColor = MaterialTheme.colorScheme.primary.toArgb()
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier,
|
||||
factory = {
|
||||
TextView(it).apply {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
autoLinkMask = Linkify.WEB_URLS
|
||||
linksClickable = true
|
||||
setTextColor(textColor)
|
||||
setLinkTextColor(linkColor)
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.maxLines = if (extended) Int.MAX_VALUE else 5
|
||||
it.text = Html.fromHtml(
|
||||
html,
|
||||
Html.FROM_HTML_MODE_LEGACY,
|
||||
CoilImageGetter(it),
|
||||
null
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Text(text = post.publishedAt.format(
|
||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CoilImageGetter(
|
||||
private val textView: TextView,
|
||||
private val maxImageWidth: Int = textView.width
|
||||
) : Html.ImageGetter {
|
||||
|
||||
override fun getDrawable(source: String): Drawable {
|
||||
val urlDrawable = UrlDrawable()
|
||||
|
||||
if (maxImageWidth <= 0) {
|
||||
textView.post {
|
||||
updateImage(
|
||||
source,
|
||||
urlDrawable,
|
||||
textView.width
|
||||
)
|
||||
}
|
||||
} else {
|
||||
updateImage(
|
||||
source,
|
||||
urlDrawable,
|
||||
maxImageWidth
|
||||
)
|
||||
}
|
||||
|
||||
return urlDrawable
|
||||
}
|
||||
|
||||
private fun updateImage(
|
||||
source: String,
|
||||
urlDrawable: UrlDrawable,
|
||||
maxWidth: Int
|
||||
) {
|
||||
val imageLoader =
|
||||
ImageLoader.Builder(textView.context)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
ImageRequest.Builder(textView.context)
|
||||
.data(source)
|
||||
.target { image ->
|
||||
val drawable =
|
||||
image.asDrawable(
|
||||
textView.resources
|
||||
)
|
||||
|
||||
val (width, height) =
|
||||
calculateScaledSize(
|
||||
drawable.intrinsicWidth,
|
||||
drawable.intrinsicHeight,
|
||||
maxWidth
|
||||
)
|
||||
|
||||
drawable.setBounds(
|
||||
0, 0,
|
||||
width, height
|
||||
)
|
||||
|
||||
urlDrawable.drawable = drawable
|
||||
|
||||
urlDrawable.setBounds(
|
||||
0, 0,
|
||||
width, height
|
||||
)
|
||||
|
||||
// заставляем TextView заново нарисовать
|
||||
textView.text = textView.text
|
||||
}
|
||||
.build()
|
||||
|
||||
imageLoader.enqueue(request)
|
||||
}
|
||||
|
||||
private fun calculateScaledSize(
|
||||
originalWidth: Int,
|
||||
originalHeight: Int,
|
||||
maxWidth: Int
|
||||
): Pair<Int, Int> {
|
||||
if (originalWidth <= maxWidth) {
|
||||
return originalWidth to originalHeight
|
||||
}
|
||||
|
||||
val ratio =
|
||||
maxWidth.toFloat() / originalWidth
|
||||
|
||||
return maxWidth to
|
||||
(originalHeight * ratio).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
class UrlDrawable() : Drawable() {
|
||||
var drawable: Drawable? = null
|
||||
set(value) {
|
||||
field = value
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
override fun draw(canvas: android.graphics.Canvas) {
|
||||
drawable?.draw(canvas)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
|
||||
override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.news
|
||||
|
||||
import android.net.http.HttpException
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresExtension
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.io.IOException
|
||||
import ru.sweetbread.unn.core.db.AppDatabase
|
||||
import ru.sweetbread.unn.core.db.entity.NewsDB
|
||||
import ru.sweetbread.unn.core.db.entity.toEntity
|
||||
import ru.sweetbread.unn.core.network.PortalApi
|
||||
import ru.sweetbread.unn.feature.profile.UserRepository
|
||||
import ru.sweetbread.unn.model.News
|
||||
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class NewsRemoteMediator(
|
||||
private val db: AppDatabase,
|
||||
private val api: PortalApi,
|
||||
private val userRepo: UserRepository
|
||||
) : RemoteMediator<Int, NewsDB>() {
|
||||
private var nextPage = 1
|
||||
|
||||
@RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, NewsDB>
|
||||
): MediatorResult {
|
||||
val page = when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
nextPage = 1
|
||||
1
|
||||
}
|
||||
|
||||
LoadType.PREPEND -> {
|
||||
return MediatorResult.Success(
|
||||
endOfPaginationReached = true
|
||||
)
|
||||
}
|
||||
|
||||
LoadType.APPEND -> { nextPage }
|
||||
}
|
||||
|
||||
return try {
|
||||
val response = api.bitrix.getNews(page)
|
||||
|
||||
val news = response.map { dto ->
|
||||
|
||||
val author = userRepo.getByUserId(dto.authorId)
|
||||
|
||||
News(
|
||||
dto.id,
|
||||
dto.publishedAt,
|
||||
dto.content,
|
||||
author
|
||||
)
|
||||
}
|
||||
|
||||
db.withTransaction {
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.newsDao().clear()
|
||||
}
|
||||
|
||||
db.newsDao().insertAll(
|
||||
news.map { it.toEntity() }
|
||||
)
|
||||
}
|
||||
|
||||
nextPage = page + 1
|
||||
|
||||
MediatorResult.Success(
|
||||
endOfPaginationReached =
|
||||
news.size < 20
|
||||
)
|
||||
|
||||
} catch (e: IOException) {
|
||||
MediatorResult.Error(e)
|
||||
} catch (e: HttpException) {
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.news
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.map
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.sweetbread.unn.core.SessionManager
|
||||
import ru.sweetbread.unn.core.db.AppDatabase
|
||||
import ru.sweetbread.unn.core.db.entity.toModel
|
||||
import ru.sweetbread.unn.core.network.PortalApi
|
||||
import ru.sweetbread.unn.feature.profile.UserRepository
|
||||
import ru.sweetbread.unn.model.News
|
||||
|
||||
class NewsRepository (
|
||||
val api: PortalApi,
|
||||
val db: AppDatabase,
|
||||
val session: SessionManager,
|
||||
val userRepository: UserRepository
|
||||
) {
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
fun getNews(): Flow<PagingData<News>> = Pager(
|
||||
config = PagingConfig(
|
||||
pageSize = 20,
|
||||
initialLoadSize = 20,
|
||||
prefetchDistance = 10,
|
||||
enablePlaceholders = false
|
||||
),
|
||||
remoteMediator = NewsRemoteMediator(
|
||||
db,
|
||||
api,
|
||||
userRepository
|
||||
),
|
||||
pagingSourceFactory = {
|
||||
db.newsDao().paging()
|
||||
}
|
||||
)
|
||||
.flow
|
||||
.map { pagingData ->
|
||||
pagingData.map { entity ->
|
||||
entity.toModel(userRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.news
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import androidx.paging.cachedIn
|
||||
import ru.sweetbread.unn.app.UNNApp
|
||||
|
||||
class NewsViewModel(
|
||||
repository: NewsRepository
|
||||
) : ViewModel() {
|
||||
val news = repository
|
||||
.getNews()
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
companion object {
|
||||
val Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = checkNotNull(
|
||||
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
|
||||
) as UNNApp
|
||||
|
||||
NewsViewModel(
|
||||
repository = application.container.newsRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.profile
|
||||
|
||||
import android.util.Log
|
||||
import ru.sweetbread.unn.core.db.AppDatabase
|
||||
import ru.sweetbread.unn.core.db.entity.toEntity
|
||||
import ru.sweetbread.unn.core.db.entity.toModel
|
||||
import ru.sweetbread.unn.core.network.PortalApi
|
||||
import ru.sweetbread.unn.model.User
|
||||
|
||||
class UserRepository (
|
||||
val api: PortalApi,
|
||||
val db: AppDatabase,
|
||||
) {
|
||||
suspend fun getByUserId(id: Int): User {
|
||||
if (id == 0) return User.getPlaceholder()
|
||||
|
||||
val fromDb = db.userDao().get(id)
|
||||
if (fromDb == null) {
|
||||
Log.d("user", "id: $id")
|
||||
val fromApi = api.bitrix.getUserByID(id)
|
||||
db.userDao().insert(fromApi.toEntity())
|
||||
return fromApi
|
||||
}
|
||||
return fromDb.toModel()
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.schedule
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.sweetbread.unn.R
|
||||
import ru.sweetbread.unn.feature.schedule.components.BoundedWeekPicker
|
||||
import ru.sweetbread.unn.feature.schedule.components.GroupsNameText
|
||||
import ru.sweetbread.unn.model.Auditorium
|
||||
import ru.sweetbread.unn.model.Building
|
||||
import ru.sweetbread.unn.model.Discipline
|
||||
import ru.sweetbread.unn.model.KindOfWork
|
||||
import ru.sweetbread.unn.model.Lecturer
|
||||
import ru.sweetbread.unn.model.LecturerRank
|
||||
import ru.sweetbread.unn.model.ScheduleUnit
|
||||
import ru.sweetbread.unn.ui.theme.UNNTheme
|
||||
import splitties.resources.appStr
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Calendar
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
@Composable
|
||||
fun Schedule() {
|
||||
val viewModel: ScheduleViewModel = viewModel(
|
||||
factory = ScheduleViewModel.Factory
|
||||
)
|
||||
|
||||
Column {
|
||||
BoundedWeekPicker(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
selectedDate = viewModel.uiState.collectAsState().value.date,
|
||||
onDateSelected = { viewModel.selectDate(it) }
|
||||
)
|
||||
|
||||
ScheduleDay(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScheduleDay(modifier: Modifier = Modifier, viewModel: ScheduleViewModel) {
|
||||
var expanded by remember { mutableIntStateOf(0) }
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val lessons = state.lessons
|
||||
LazyColumn(modifier) {
|
||||
items(
|
||||
items = lessons,
|
||||
key = { it.oid }
|
||||
) {
|
||||
ScheduleItem(unit = it, modifier = Modifier.clickable {
|
||||
expanded = if (it.oid == expanded) 0
|
||||
else it.oid
|
||||
}, expanded = expanded == it.oid)
|
||||
}
|
||||
|
||||
if (state.isRefreshing) {
|
||||
item { LinearProgressIndicator(Modifier.fillMaxWidth()) }
|
||||
} else {
|
||||
if (lessons.isEmpty())
|
||||
item { Text(appStr(R.string.noData)) }
|
||||
}
|
||||
|
||||
state.error?.let { item { Text(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScheduleItem(modifier: Modifier = Modifier, unit: ScheduleUnit, expanded: Boolean = false) {
|
||||
fun getRatio(): Float {
|
||||
val begin = LocalDateTime.of(unit.date, unit.begin).atZone(ZoneId.of("Europe/Moscow")).toEpochSecond()
|
||||
val end = LocalDateTime.of(unit.date, unit.end).atZone(ZoneId.of("Europe/Moscow")).toEpochSecond()
|
||||
val now = LocalDateTime.now().atZone(ZoneId.of("Europe/Moscow")).toEpochSecond()
|
||||
if (begin > now)
|
||||
return -1f
|
||||
if (now > end)
|
||||
return 1f
|
||||
return (now - begin) / (end - begin).toFloat()
|
||||
}
|
||||
|
||||
val begin = unit.begin.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
val end = unit.end.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
var ratio by remember(
|
||||
unit.date,
|
||||
unit.begin,
|
||||
unit.end
|
||||
) {
|
||||
mutableFloatStateOf(getRatio())
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
unit.date,
|
||||
unit.begin,
|
||||
unit.end
|
||||
) {
|
||||
ratio = getRatio()
|
||||
|
||||
while (true) {
|
||||
val now = System.currentTimeMillis()
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
timeInMillis = now
|
||||
}
|
||||
|
||||
val seconds = calendar.get(Calendar.SECOND)
|
||||
val millisUntilNextMinute =
|
||||
(60 - seconds) * 1000L -
|
||||
calendar.get(Calendar.MILLISECOND)
|
||||
|
||||
delay(millisUntilNextMinute.milliseconds)
|
||||
|
||||
ratio = getRatio()
|
||||
}
|
||||
}
|
||||
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when (ratio) {
|
||||
1f -> MaterialTheme.colorScheme.surfaceContainer
|
||||
-1f -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
},
|
||||
label = "backgroundTransition"
|
||||
)
|
||||
|
||||
Row (
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(backgroundColor)
|
||||
.padding(8.dp)
|
||||
){
|
||||
Column (Modifier.weight(1f)) {
|
||||
Column {
|
||||
Text(
|
||||
text = unit.discipline.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.zIndex(1f),
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility (expanded) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = unit.kindOfWork.name,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
AnimatedVisibility (expanded) {
|
||||
GroupsNameText(
|
||||
unit.stream,
|
||||
highlightColor = MaterialTheme.colorScheme.inversePrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility (expanded) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility (!expanded) {
|
||||
Row(Modifier) {
|
||||
Text(
|
||||
text = unit.auditorium.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = unit.auditorium.building.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility (expanded) {
|
||||
Column {
|
||||
Text(
|
||||
text = "${stringResource(R.string.auditorium)}: ${unit.auditorium.name}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = "${stringResource(R.string.building)}: ${unit.auditorium.building.name}",
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (unit.auditorium.floor != 0) {
|
||||
Text(
|
||||
text = "${stringResource(R.string.floor)}: ${unit.auditorium.floor}",
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(text = unit.lecturers[0].name, fontWeight = FontWeight.Bold)
|
||||
Text(text = stringResource(unit.lecturers[0].rank.id))
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Row (Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(begin.toString(), fontWeight = FontWeight.Bold)
|
||||
AnimatedVisibility (
|
||||
(0f <= ratio) and (ratio < 1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 2.dp)
|
||||
) {
|
||||
DividerWithMarker(
|
||||
positionPercentage = ratio,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
thickness = 3.dp,
|
||||
markerSize = 8.dp,
|
||||
markerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text(end.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility (!expanded) {
|
||||
Column {
|
||||
Text(begin.toString(), fontWeight = FontWeight.Bold)
|
||||
Text(end.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun DividerWithMarker(
|
||||
modifier: Modifier = Modifier,
|
||||
positionPercentage: Float,
|
||||
color: Color = Color.Gray,
|
||||
thickness: Dp = 1.dp,
|
||||
markerSize: Dp = 8.dp,
|
||||
markerColor: Color = Color.Red
|
||||
) {
|
||||
Canvas(modifier = modifier.height(thickness)) {
|
||||
val dividerHeight = thickness.toPx()
|
||||
val width = size.width
|
||||
val markerX = width * positionPercentage
|
||||
|
||||
drawLine(
|
||||
color = markerColor,
|
||||
start = Offset(0f, dividerHeight / 2),
|
||||
end = Offset(markerX, dividerHeight / 2),
|
||||
strokeWidth = dividerHeight
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(markerX, dividerHeight / 2),
|
||||
end = Offset(width, dividerHeight / 2),
|
||||
strokeWidth = dividerHeight / 2
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = markerColor,
|
||||
radius = markerSize.toPx() / 2,
|
||||
center = Offset(markerX, dividerHeight / 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ScheduleItemPreview() {
|
||||
val unit = ScheduleUnit(
|
||||
oid = 1,
|
||||
Auditorium(
|
||||
name = "с/з 1(110)",
|
||||
oid = 3752,
|
||||
floor = 0,
|
||||
building = Building(
|
||||
name = "Корпус 6",
|
||||
gid = 30,
|
||||
oid = 155
|
||||
),
|
||||
),
|
||||
date = LocalDate.of(2024, 3, 11),
|
||||
discipline = Discipline(
|
||||
name = "Физическая культура и спорт (элективная дисциплина)",
|
||||
oid = 67895,
|
||||
type = 0
|
||||
),
|
||||
kindOfWork = KindOfWork(
|
||||
name = "Практика (семинарские занятия)",
|
||||
oid = 261,
|
||||
uid = "281474976710661",
|
||||
complexity = 1
|
||||
),
|
||||
lecturers = arrayListOf(
|
||||
Lecturer(
|
||||
name = "Фамилия Имя Отчество",
|
||||
rank = LecturerRank.SLecturer,
|
||||
email = "",
|
||||
unnId = 28000,
|
||||
uid = "51000"
|
||||
)
|
||||
),
|
||||
stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP",
|
||||
begin = LocalTime.of(10, 50),
|
||||
end = LocalTime.of(12, 20),
|
||||
LocalDateTime.now()
|
||||
)
|
||||
|
||||
UNNTheme {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
ScheduleItem(unit = unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ScheduleExpandedItemPreview() {
|
||||
val unit = ScheduleUnit(
|
||||
oid = 1,
|
||||
Auditorium(
|
||||
name = "с/з 1(110)",
|
||||
oid = 3752,
|
||||
floor = 0,
|
||||
building = Building(
|
||||
name = "Корпус 6",
|
||||
gid = 30,
|
||||
oid = 155
|
||||
),
|
||||
),
|
||||
date = LocalDate.of(2024, 3, 11),
|
||||
discipline = Discipline(
|
||||
name = "Физическая культура и спорт (элективная дисциплина)",
|
||||
oid = 67895,
|
||||
type = 0
|
||||
),
|
||||
kindOfWork = KindOfWork(
|
||||
name = "Практика (семинарские занятия)",
|
||||
oid = 261,
|
||||
uid = "281474976710661",
|
||||
complexity = 1
|
||||
),
|
||||
lecturers = arrayListOf(
|
||||
Lecturer(
|
||||
name = "Фамилия Имя Отчество",
|
||||
rank = LecturerRank.SLecturer,
|
||||
email = "",
|
||||
unnId = 28000,
|
||||
uid = "51000"
|
||||
)
|
||||
),
|
||||
stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP",
|
||||
begin = LocalTime.of(10, 50),
|
||||
end = LocalTime.of(12, 20),
|
||||
LocalDateTime.now()
|
||||
)
|
||||
|
||||
UNNTheme {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
ScheduleItem(unit = unit, expanded = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.schedule
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.sweetbread.unn.core.db.AppDatabase
|
||||
import ru.sweetbread.unn.core.db.entity.cacheSchedule
|
||||
import ru.sweetbread.unn.core.db.entity.toModel
|
||||
import ru.sweetbread.unn.core.network.PortalApi
|
||||
import ru.sweetbread.unn.model.ScheduleUnit
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class ScheduleRepository (
|
||||
val api: PortalApi,
|
||||
val db: AppDatabase,
|
||||
) {
|
||||
fun observeSchedule(date: LocalDate): Flow<List<ScheduleUnit>> =
|
||||
db.scheduleDao()
|
||||
.observe(date.format(DateTimeFormatter.ISO_DATE))
|
||||
.map { ent -> ent.mapNotNull { it.toModel(db) } }
|
||||
|
||||
suspend fun refresh(date: LocalDate, diffHrs: Long = 24) {
|
||||
val dateStr = date.format(DateTimeFormatter.ISO_DATE)
|
||||
|
||||
val fromDb = db.scheduleDao()
|
||||
.getSchedule(dateStr)
|
||||
.mapNotNull { it.toModel(db) }
|
||||
|
||||
if (
|
||||
fromDb.isEmpty() ||
|
||||
fromDb.minOf { it.lastUpdated } < LocalDateTime.now().minusHours(diffHrs)
|
||||
) {
|
||||
val fromApi = api.ruzapi.getScheduleByUns(from = date, until = date)
|
||||
|
||||
db.withTransaction {
|
||||
db.scheduleDao().deleteByDate(dateStr)
|
||||
cacheSchedule(db, fromApi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.schedule
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.sweetbread.unn.app.UNNApp
|
||||
import ru.sweetbread.unn.model.ScheduleUnit
|
||||
import java.time.LocalDate
|
||||
|
||||
data class ScheduleUiState(
|
||||
val date: LocalDate = LocalDate.now(),
|
||||
val isRefreshing: Boolean = false,
|
||||
val error: String? = null,
|
||||
val lessons: List<ScheduleUnit> = emptyList(),
|
||||
)
|
||||
|
||||
class ScheduleViewModel(
|
||||
private val repository: ScheduleRepository
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
val Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = checkNotNull(
|
||||
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
|
||||
) as UNNApp
|
||||
|
||||
ScheduleViewModel(
|
||||
repository = application.container.scheduleRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val date = MutableStateFlow(LocalDate.now())
|
||||
private val refreshing = MutableStateFlow(false)
|
||||
private val error = MutableStateFlow<String?>(null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val lessons =
|
||||
date.flatMapLatest(repository::observeSchedule)
|
||||
|
||||
val uiState =
|
||||
combine(
|
||||
date,
|
||||
refreshing,
|
||||
error,
|
||||
lessons,
|
||||
) { date, refreshing, error, lessons ->
|
||||
ScheduleUiState(
|
||||
date = date,
|
||||
isRefreshing = refreshing,
|
||||
error = error,
|
||||
lessons = lessons
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
ScheduleUiState()
|
||||
)
|
||||
|
||||
fun selectDate(date: LocalDate) {
|
||||
this.date.value = date
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
refreshing.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
repository.refresh(date.value)
|
||||
} catch (e: Exception) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.feature.schedule.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun GroupsNameText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
highlightColor: Color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
highlightPadding: PaddingValues = PaddingValues(horizontal = 6.dp, vertical = 2.dp),
|
||||
highlightShape: Shape = MaterialTheme.shapes.small,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
) {
|
||||
val suffix = if (text.endsWith("-В-OUP")) "-В-OUP" else null
|
||||
val mainPart = if (suffix != null) text.dropLast(6) else text
|
||||
val blocks = if (mainPart.startsWith("Поток_(") && mainPart.endsWith(')')) {
|
||||
mainPart
|
||||
.removePrefix("Поток_(")
|
||||
.dropLast(1)
|
||||
.split(';')
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
} else {
|
||||
mainPart.split('|')
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
blocks.forEach { block ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = 2.dp)
|
||||
.background(highlightColor, highlightShape)
|
||||
.padding(highlightPadding)
|
||||
) {
|
||||
Text(text = block)
|
||||
}
|
||||
}
|
||||
|
||||
suffix?.let { Text(text = it) }
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
package ru.sweetbread.unn.feature.schedule.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
@Composable
|
||||
fun BoundedWeekPicker(
|
||||
modifier: Modifier = Modifier,
|
||||
selectedDate: LocalDate,
|
||||
onDateSelected: (LocalDate) -> Unit,
|
||||
baseDate: LocalDate = LocalDate.now()
|
||||
) {
|
||||
val rangeStart = remember(baseDate) {
|
||||
baseDate
|
||||
.with(DayOfWeek.MONDAY)
|
||||
.minusWeeks(2)
|
||||
}
|
||||
|
||||
val pageCount = 2 /* prev */ + 1 /* cur */ + 5 /* next */
|
||||
|
||||
fun pageIndexForDate(date: LocalDate): Int {
|
||||
val weekStart = date.with(DayOfWeek.MONDAY)
|
||||
return ChronoUnit.WEEKS.between(rangeStart, weekStart)
|
||||
.toInt()
|
||||
.coerceIn(0, pageCount - 1)
|
||||
}
|
||||
|
||||
val initialPage = remember(selectedDate) { pageIndexForDate(selectedDate) }
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialPage,
|
||||
pageCount = { pageCount }
|
||||
)
|
||||
|
||||
LaunchedEffect(selectedDate) {
|
||||
val targetPage = pageIndexForDate(selectedDate)
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
beyondViewportPageCount = 1,
|
||||
pageSpacing = 0.dp
|
||||
) { page ->
|
||||
val weekStart = rangeStart.plusWeeks(page.toLong())
|
||||
val days = List(6) { weekStart.plusDays(it.toLong()) }
|
||||
|
||||
WeekPage(
|
||||
days = days,
|
||||
selectedDate = selectedDate,
|
||||
onDateSelected = onDateSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekPage(
|
||||
days: List<LocalDate>,
|
||||
selectedDate: LocalDate,
|
||||
onDateSelected: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
days.forEach { date ->
|
||||
DayItem(
|
||||
date = date,
|
||||
isSelected = date == selectedDate,
|
||||
onClick = { onDateSelected(date) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayItem(
|
||||
date: LocalDate,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||
val contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
val fontWeight = if (date == LocalDate.now()) FontWeight.ExtraBold else FontWeight.Normal
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor,
|
||||
fontWeight = fontWeight
|
||||
)
|
||||
Text(
|
||||
text = date.dayOfMonth.toString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor,
|
||||
fontWeight = fontWeight
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.model
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
|
||||
class News (
|
||||
val id: Int,
|
||||
val publishedAt: LocalDateTime,
|
||||
val content: String,
|
||||
val author: User
|
||||
)
|
||||
|
||||
class NewsDto (
|
||||
val id: Int,
|
||||
val publishedAt: LocalDateTime,
|
||||
val content: String,
|
||||
val authorId: Int,
|
||||
val authorLogin: String
|
||||
// val comments: ArrayList<NewsComment>,
|
||||
// val important: Boolean,
|
||||
// val pinned: Boolean
|
||||
) {
|
||||
constructor(json: JSONObject) : this (
|
||||
json.getString("id").toInt(),
|
||||
LocalDateTime.parse(
|
||||
json.getString("time"),
|
||||
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss")
|
||||
),
|
||||
json.getString("fulltext"),
|
||||
json.getJSONObject("author").getString("user_id").toInt(),
|
||||
json.getJSONObject("author").getString("login")
|
||||
// NewsComment.fromJsonArray(json.getJSONArray("comments")),
|
||||
// json.getBoolean("important"),
|
||||
// json.getBoolean("pinned")
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromJsonArray(arr: JSONArray): ArrayList<NewsDto> {
|
||||
val out: ArrayList<NewsDto> = arrayListOf()
|
||||
for (i in 0 until arr.length()) {
|
||||
out.add(NewsDto(arr.getJSONObject(i)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//class NewsComment() {
|
||||
// companion object {
|
||||
// fun fromJsonArray(json: JSONArray): ArrayList<NewsComment> {
|
||||
//
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.model
|
||||
|
||||
import ru.sweetbread.unn.R
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
|
||||
class ScheduleUnit(
|
||||
val oid: Int,
|
||||
val auditorium: Auditorium,
|
||||
val date: LocalDate,
|
||||
val discipline: Discipline,
|
||||
val kindOfWork: KindOfWork,
|
||||
val lecturers: List<Lecturer>,
|
||||
val stream: String,
|
||||
val begin: LocalTime,
|
||||
val end: LocalTime,
|
||||
val lastUpdated: LocalDateTime,
|
||||
)
|
||||
|
||||
enum class LecturerRank(val id: Int) {
|
||||
Assistant(R.string.assistant),
|
||||
Lecturer(R.string.lecturer),
|
||||
SLecturer(R.string.slecturer),
|
||||
AProfessor(R.string.aprofessor)
|
||||
}
|
||||
|
||||
class Auditorium(
|
||||
val name: String,
|
||||
val oid: Int,
|
||||
val floor: Int,
|
||||
val building: Building
|
||||
)
|
||||
|
||||
class Building(
|
||||
val name: String,
|
||||
val gid: Int,
|
||||
val oid: Int
|
||||
)
|
||||
|
||||
class Discipline(
|
||||
val name: String,
|
||||
val oid: Int,
|
||||
val type: Int
|
||||
)
|
||||
|
||||
class KindOfWork(
|
||||
val name: String,
|
||||
val oid: Int,
|
||||
val uid: String,
|
||||
val complexity: Int
|
||||
)
|
||||
|
||||
class Lecturer(
|
||||
val name: String,
|
||||
val rank: LecturerRank,
|
||||
val email: String,
|
||||
val unnId: Int,
|
||||
val uid: String
|
||||
)
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.model
|
||||
|
||||
import org.json.JSONObject
|
||||
import ru.sweetbread.unn.R
|
||||
import splitties.resources.appStr
|
||||
|
||||
enum class Type(val s: String) {
|
||||
Student(appStr(R.string.student)),
|
||||
Lecturer(appStr(R.string.lecturer)),
|
||||
Employee(appStr(R.string.employee))
|
||||
}
|
||||
|
||||
open class User(
|
||||
open val userId: Int, // vuzapi ID
|
||||
// val bxId: Int, // Bitrix user ID
|
||||
// val raId: Int?, // ruzapi ID (for schedule)
|
||||
open val type: Type,
|
||||
open val email: String,
|
||||
open val nameRu: String,
|
||||
open val nameEn: String,
|
||||
open val avatar: AvatarSet
|
||||
) {
|
||||
constructor(json: JSONObject) : this(
|
||||
json.getInt("id"),
|
||||
when (
|
||||
json.getJSONArray("profiles")
|
||||
.getJSONObject(0)
|
||||
.getString("type")
|
||||
) {
|
||||
"student" -> Type.Student
|
||||
else -> Type.Employee
|
||||
},
|
||||
json.getString("email"),
|
||||
json.getString("fullname"),
|
||||
json.getString("fullname_en"),
|
||||
json.getJSONObject("photo").let {
|
||||
AvatarSet(
|
||||
it.getString("orig"),
|
||||
it.getString("thumbnail"),
|
||||
it.getString("small"),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromProfile(json: JSONObject): User {
|
||||
val user = json.getJSONObject("user")
|
||||
return User(
|
||||
user.getInt("id"),
|
||||
when (json.getString("type")) {
|
||||
"student" -> Type.Student
|
||||
else -> Type.Employee
|
||||
},
|
||||
user.getString("email"),
|
||||
user.getString("fullname"),
|
||||
user.getString("fullname_en"),
|
||||
user.getJSONObject("photo").let {
|
||||
AvatarSet(
|
||||
it.getString("orig"),
|
||||
it.getString("thumbnail"),
|
||||
it.getString("small"),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun getPlaceholder() = User(
|
||||
0,
|
||||
Type.Employee,
|
||||
"",
|
||||
"Удалённый пользователь",
|
||||
"Deleted user",
|
||||
AvatarSet("", "", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Student(
|
||||
val uns: Int?,
|
||||
override val userId: Int,
|
||||
override val type: Type,
|
||||
override val email: String,
|
||||
override val nameRu: String,
|
||||
override val nameEn: String,
|
||||
override val avatar: AvatarSet,
|
||||
val group: String
|
||||
) : User (
|
||||
userId,
|
||||
type,
|
||||
email,
|
||||
nameRu,
|
||||
nameEn,
|
||||
avatar,
|
||||
) {
|
||||
constructor(uns: Int? = null, user: User, group: String) : this (
|
||||
uns,
|
||||
user.userId,
|
||||
user.type,
|
||||
user.email,
|
||||
user.nameRu,
|
||||
user.nameEn,
|
||||
user.avatar,
|
||||
group
|
||||
)
|
||||
}
|
||||
|
||||
class AvatarSet(
|
||||
val original: String,
|
||||
val thumbnail: String,
|
||||
val small: String
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
package ru.sweetbread.unn.ui
|
||||
|
||||
import android.util.Log
|
||||
import io.ktor.client.request.forms.submitForm
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.parameters
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.sweetbread.unn.ui.layout.LoginData
|
||||
import ru.sweetbread.unn.ui.layout.client
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private lateinit var PHPSESSID: String
|
||||
lateinit var ME: User
|
||||
|
||||
const val portalURL = "https://portal.unn.ru"
|
||||
const val ruzapiURL = "$portalURL/ruzapi"
|
||||
|
||||
enum class Type(val s: String) {
|
||||
Student("student"),
|
||||
Group("group"),
|
||||
Lecturer("lecturer"),
|
||||
Auditorium("auditorium")
|
||||
}
|
||||
|
||||
enum class LecturerRank(val s: String) {
|
||||
Lecturer("Lecturer"),
|
||||
SLecturer("Senior Lecturer")
|
||||
}
|
||||
|
||||
class ScheduleUnit(
|
||||
val auditorium: Auditorium,
|
||||
val date: LocalDate,
|
||||
val discipline: Discipline,
|
||||
val kindOfWork: KindOfWork,
|
||||
val lecturers: ArrayList<Lecturer>,
|
||||
val stream: String,
|
||||
val begin: LocalTime,
|
||||
val end: LocalTime)
|
||||
|
||||
class Auditorium (val name: String,
|
||||
val oid: Int,
|
||||
val floor: Int,
|
||||
val building: Building)
|
||||
class Building(val name: String,
|
||||
val gid: Int,
|
||||
val oid: Int)
|
||||
|
||||
class Discipline (val name: String,
|
||||
val oid: Int,
|
||||
val type: Int)
|
||||
|
||||
class KindOfWork (val name: String,
|
||||
val oid: Int,
|
||||
val uid: String,
|
||||
val complexity: Int)
|
||||
|
||||
class Lecturer (val name: String,
|
||||
val rank: LecturerRank,
|
||||
val email: String,
|
||||
val oid: Int,
|
||||
val uid: String)
|
||||
|
||||
class User (val id: String,
|
||||
val uns: String,
|
||||
val type: Type,
|
||||
val email: String,
|
||||
val name: String,
|
||||
val info: String)
|
||||
|
||||
/**
|
||||
* Authorize user by [login] and [password]
|
||||
*
|
||||
* Also defines local vars [PHPSESSID] and [ME.id]
|
||||
*/
|
||||
suspend fun auth(login: String = LoginData.login, password: String = LoginData.password, forced: Boolean = false): Boolean {
|
||||
if (!forced) {
|
||||
if (::PHPSESSID.isInitialized and ::ME.isInitialized)
|
||||
return true
|
||||
}
|
||||
val r = client.submitForm("$portalURL/auth/?login=yes",
|
||||
formParameters = parameters {
|
||||
append("AUTH_FORM", "Y")
|
||||
append("TYPE", "AUTH")
|
||||
append("backurl", "/")
|
||||
append("USER_LOGIN", login)
|
||||
append("USER_PASSWORD", password)
|
||||
}
|
||||
)
|
||||
if (r.status.value == 302) {
|
||||
PHPSESSID = """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
|
||||
getMyself(login)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun getMyself(login: String) {
|
||||
val r = client.get("$ruzapiURL/studentinfo") {
|
||||
parameter("uns", login.substring(1))
|
||||
}
|
||||
val json = JSONObject(r.bodyAsText())
|
||||
ME = User(
|
||||
id = json.getString("id"),
|
||||
uns = json.getString("uns"),
|
||||
type = when(json.getString("type")) {
|
||||
"lecturer" -> Type.Lecturer // ig,,,
|
||||
else -> Type.Student
|
||||
},
|
||||
email = json.getString("email"),
|
||||
name = json.getString("fio"),
|
||||
info = json.getString("info")
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getSchedule(type: Type = Type.Student, id: String = ME.id, start: LocalDate, finish: LocalDate): ArrayList<ScheduleUnit> {
|
||||
val r = client.get("$ruzapiURL/schedule/${type.s}/$id") {
|
||||
parameter("start", start.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")))
|
||||
parameter("finish", finish.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")))
|
||||
parameter("lng", "1")
|
||||
}
|
||||
val json = JSONArray(r.bodyAsText())
|
||||
val out = arrayListOf<ScheduleUnit>()
|
||||
for (i in 0 until json.length()) {
|
||||
val unit = json.getJSONObject(i)
|
||||
val lecturesJson = unit.getJSONArray("listOfLecturers")
|
||||
val lecturers = arrayListOf<Lecturer>()
|
||||
|
||||
for (j in 0 until lecturesJson.length()) {
|
||||
val lecturer = lecturesJson.getJSONObject(j)
|
||||
lecturers.add(
|
||||
Lecturer(
|
||||
name = lecturer.getString("lecturer"),
|
||||
email = lecturer.getString("lecturerEmail"),
|
||||
oid = lecturer.getInt("lecturerOid"),
|
||||
uid = lecturer.getString("lecturerUID"),
|
||||
rank = when (lecturer.getString("lecturer_rank")) {
|
||||
"СТПРЕП" -> LecturerRank.SLecturer
|
||||
else -> LecturerRank.Lecturer
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
out.add(
|
||||
ScheduleUnit(
|
||||
auditorium = Auditorium(
|
||||
name = unit.getString("auditorium"),
|
||||
oid = unit.getInt("auditoriumOid"),
|
||||
floor = unit.getInt("auditoriumfloor"),
|
||||
building = Building(
|
||||
name = unit.getString("building"),
|
||||
gid = unit.getInt("buildingGid"),
|
||||
oid = unit.getInt("buildingOid")
|
||||
)
|
||||
),
|
||||
date = LocalDate.parse(unit.getString("date"), DateTimeFormatter.ofPattern("yyyy.MM.dd")),
|
||||
discipline = Discipline(
|
||||
name = unit.getString("discipline"),
|
||||
oid = unit.getInt("disciplineOid"),
|
||||
type = unit.getInt("disciplinetypeload")
|
||||
),
|
||||
kindOfWork = KindOfWork(
|
||||
name = unit.getString("kindOfWork"),
|
||||
complexity = unit.getInt("kindOfWorkComplexity"),
|
||||
oid = unit.getInt("kindOfWorkOid"),
|
||||
uid = unit.getString("kindOfWorkUid")
|
||||
),
|
||||
lecturers = lecturers,
|
||||
stream = unit.getString("stream"),
|
||||
begin = LocalTime.parse(unit.getString("beginLesson"), DateTimeFormatter.ofPattern("HH:mm")),
|
||||
end = LocalTime.parse(unit.getString("endLesson"), DateTimeFormatter.ofPattern("HH:mm"))
|
||||
)
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package ru.sweetbread.unn.ui.composes
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.kizitonwose.calendar.compose.WeekCalendar
|
||||
import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.sweetbread.unn.ui.Auditorium
|
||||
import ru.sweetbread.unn.ui.Building
|
||||
import ru.sweetbread.unn.ui.Discipline
|
||||
import ru.sweetbread.unn.ui.KindOfWork
|
||||
import ru.sweetbread.unn.ui.Lecturer
|
||||
import ru.sweetbread.unn.ui.LecturerRank
|
||||
import ru.sweetbread.unn.ui.ScheduleUnit
|
||||
import ru.sweetbread.unn.ui.getSchedule
|
||||
import ru.sweetbread.unn.ui.theme.UNNTheme
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun Schedule() {
|
||||
val state = rememberWeekCalendarState(
|
||||
firstDayOfWeek = DayOfWeek.MONDAY
|
||||
)
|
||||
|
||||
Column {
|
||||
var curDate by remember { mutableStateOf(LocalDate.now()) }
|
||||
WeekCalendar(
|
||||
state = state,
|
||||
dayContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
.aspectRatio(1f) // This is important for square sizing!
|
||||
.offset(2.dp)
|
||||
.background(if (it.date == curDate) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable(
|
||||
onClick = {
|
||||
curDate = it.date
|
||||
Log.d("Here bug (olClick)",
|
||||
curDate.format(DateTimeFormatter.ISO_DATE)
|
||||
)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = it.date.dayOfMonth.toString(),
|
||||
fontWeight = if (it.date == LocalDate.now()) FontWeight.Bold else null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
ScheduleDay(date = curDate)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScheduleDay(modifier: Modifier = Modifier, date: LocalDate) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var loadedDate by remember { mutableStateOf(LocalDate.MIN) }
|
||||
val lessons = remember { mutableListOf<ScheduleUnit>() }
|
||||
|
||||
if (loadedDate == date) {
|
||||
Log.d("Loaded", "${date.format(DateTimeFormatter.ISO_DATE)} ${lessons.size}")
|
||||
LazyColumn (modifier) {
|
||||
items(lessons) {
|
||||
ScheduleItem(unit = it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
trackColor = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
LaunchedEffect(date != loadedDate) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
lessons.clear()
|
||||
lessons.addAll(getSchedule(start = date, finish = date))
|
||||
loadedDate = date
|
||||
Log.d("Loading", "${date.format(DateTimeFormatter.ISO_DATE)} ${lessons.size}")
|
||||
Log.d("Here bug", "${loadedDate.format(DateTimeFormatter.ISO_DATE)} ${date.format(DateTimeFormatter.ISO_DATE)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScheduleItem(modifier: Modifier = Modifier, unit: ScheduleUnit) {
|
||||
Row (
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(8.dp)
|
||||
){
|
||||
Column (Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = unit.discipline.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.zIndex(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(text = unit.kindOfWork.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row (Modifier) {
|
||||
Text(text = unit.auditorium.name, fontWeight = FontWeight.Bold, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(text = unit.auditorium.building.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
val begin = unit.begin.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
val end = unit.end.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
Text(begin.toString(), fontWeight = FontWeight.Bold)
|
||||
Text(end.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ScheduleItemPreview() {
|
||||
val unit = ScheduleUnit(
|
||||
Auditorium(
|
||||
name = "с/з 1(110)",
|
||||
oid = 3752,
|
||||
floor = 0,
|
||||
building = Building(
|
||||
name = "Корпус 6",
|
||||
gid = 30,
|
||||
oid = 155
|
||||
),
|
||||
),
|
||||
date = LocalDate.of(2024, 3, 11),
|
||||
discipline = Discipline(
|
||||
name = "Физическая культура и спорт (элективная дисциплина)",
|
||||
oid = 67895,
|
||||
type = 0
|
||||
),
|
||||
kindOfWork = KindOfWork(
|
||||
name = "Практика (семинарские занятия)",
|
||||
oid = 261,
|
||||
uid = "281474976710661",
|
||||
complexity = 1
|
||||
),
|
||||
lecturers = arrayListOf(
|
||||
Lecturer(
|
||||
name = "Фамилия Имя Отчество",
|
||||
rank = LecturerRank.SLecturer,
|
||||
email = "",
|
||||
oid = 28407,
|
||||
uid = "51769"
|
||||
)
|
||||
),
|
||||
stream = "3823Б1ПР1|3823Б1ПР2|3823Б1ПР3|3823Б1ПР4|3823Б1ПР5-В-OUP",
|
||||
begin = LocalTime.of(10, 50),
|
||||
end = LocalTime.of(12, 20)
|
||||
)
|
||||
|
||||
UNNTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
ScheduleItem(unit = unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.app
|
||||
package ru.sweetbread.unn.ui.layout
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -9,20 +7,19 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -36,26 +33,17 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.sweetbread.unn.R
|
||||
import ru.sweetbread.unn.core.AuthRepository
|
||||
import ru.sweetbread.unn.ui.auth
|
||||
import ru.sweetbread.unn.ui.theme.UNNTheme
|
||||
import splitties.activities.start
|
||||
|
||||
|
||||
class LoginActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val authRepo = (application as UNNApp).container.authRepo
|
||||
|
||||
if (authRepo.hasSavedCredentials()) {
|
||||
start<MainActivity>()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContent {
|
||||
UNNTheme {
|
||||
Surface(
|
||||
@@ -70,27 +58,20 @@ class LoginActivity : ComponentActivity() {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(), Alignment.Center) {
|
||||
LoginPanel(
|
||||
Modifier.imePadding(),
|
||||
authRepo,
|
||||
{
|
||||
start<MainActivity>()
|
||||
finish()
|
||||
},
|
||||
{
|
||||
scope.launch {
|
||||
snackbarHostState
|
||||
.showSnackbar(
|
||||
message = "Error",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
LoginPanel(Modifier.padding(innerPadding), { login, password ->
|
||||
LoginData.login = login
|
||||
LoginData.password = password
|
||||
start<MainActivity>()
|
||||
}, {
|
||||
scope.launch {
|
||||
|
||||
snackbarHostState
|
||||
.showSnackbar(
|
||||
message = "Error",
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,8 +82,7 @@ class LoginActivity : ComponentActivity() {
|
||||
@Composable
|
||||
fun LoginPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
authRepo: AuthRepository,
|
||||
ok: () -> Unit,
|
||||
ok: (login: String, password: String) -> Unit,
|
||||
error: () -> Unit
|
||||
) {
|
||||
var login by rememberSaveable { mutableStateOf("") }
|
||||
@@ -110,52 +90,56 @@ fun LoginPanel(
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.padding(32.dp, 0.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
value = login,
|
||||
onValueChange = { login = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.prompt_login))},
|
||||
placeholder = { Text("s23380101") }
|
||||
)
|
||||
Box(Modifier.fillMaxSize(), Alignment.BottomCenter) {
|
||||
Column(
|
||||
modifier
|
||||
.padding(32.dp, 0.dp)
|
||||
.clip(RoundedCornerShape(10.dp, 10.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
value = login,
|
||||
onValueChange = { login = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.prompt_login)) }
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.prompt_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
TextField(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.prompt_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
enabled = login.trim().isNotEmpty() and password.trim().isNotEmpty() and !loading,
|
||||
onClick = {
|
||||
Button(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp), onClick = {
|
||||
loading = true
|
||||
scope.launch {
|
||||
if (authRepo.login(login, password))
|
||||
ok()
|
||||
else
|
||||
if (auth(login, password)) {
|
||||
ok(login, password)
|
||||
} else {
|
||||
error()
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
}
|
||||
) {
|
||||
if (loading)
|
||||
CircularProgressIndicator()
|
||||
else
|
||||
}) {
|
||||
Text(stringResource(R.string.sign_in))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun LoginPreview() {
|
||||
UNNTheme {
|
||||
LoginPanel(ok = { _, _ -> }, error = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package ru.sweetbread.unn.ui.layout
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountBox
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.kizitonwose.calendar.compose.WeekCalendar
|
||||
import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.cache.HttpCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.sweetbread.unn.ui.Auditorium
|
||||
import ru.sweetbread.unn.ui.Building
|
||||
import ru.sweetbread.unn.ui.Discipline
|
||||
import ru.sweetbread.unn.ui.KindOfWork
|
||||
import ru.sweetbread.unn.ui.Lecturer
|
||||
import ru.sweetbread.unn.ui.LecturerRank
|
||||
import ru.sweetbread.unn.ui.ScheduleUnit
|
||||
import ru.sweetbread.unn.ui.auth
|
||||
import ru.sweetbread.unn.ui.getSchedule
|
||||
import ru.sweetbread.unn.ui.theme.UNNTheme
|
||||
import splitties.activities.start
|
||||
import splitties.preferences.Preferences
|
||||
import splitties.toast.toast
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import ru.sweetbread.unn.ui.composes.Schedule
|
||||
import ru.sweetbread.unn.ui.composes.ScheduleDay
|
||||
|
||||
object LoginData : Preferences("loginData") {
|
||||
var login by stringPref("login", "")
|
||||
var password by stringPref("password", "")
|
||||
}
|
||||
|
||||
val client = HttpClient {
|
||||
install(HttpCache)
|
||||
install(Logging) {
|
||||
logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
Log.i("Ktor", message)
|
||||
}
|
||||
}
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
socketTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
|
||||
}
|
||||
install(HttpRequestRetry) {
|
||||
retryOnServerErrors(maxRetries = 3)
|
||||
exponentialDelay()
|
||||
modifyRequest { request ->
|
||||
request.headers.append("x-retry-count", retryCount.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
if (LoginData.login.isEmpty() or LoginData.password.isEmpty()) start<LoginActivity>()
|
||||
runBlocking {
|
||||
if (!auth()) start<LoginActivity>()
|
||||
}
|
||||
|
||||
setContent {
|
||||
UNNTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
onClick = { toast("Not implemented") },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.Home,
|
||||
contentDescription = "Home"
|
||||
)
|
||||
},
|
||||
selected = navController.currentDestination?.route?.startsWith("home") ?: false
|
||||
)
|
||||
NavigationBarItem(
|
||||
onClick = { navController.navigate("schedule/student/me/today")
|
||||
Log.d("route", navController.currentDestination?.route.toString())},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.DateRange,
|
||||
contentDescription = "Schedule"
|
||||
)
|
||||
},
|
||||
selected = navController.currentDestination?.route?.startsWith("schedule") ?: false
|
||||
)
|
||||
NavigationBarItem(
|
||||
onClick = { toast("Not implemented") },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.AccountBox,
|
||||
contentDescription = "Account"
|
||||
)
|
||||
},
|
||||
selected = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
) {innerPadding ->
|
||||
Box(Modifier.padding(innerPadding)) {
|
||||
NavHost(navController, startDestination = "home/blogposts") {
|
||||
composable("home/blogposts") {
|
||||
Text("Not implemented")
|
||||
}
|
||||
composable("schedule/{type}/{who}/{when}",
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("who") { type = NavType.StringType },
|
||||
navArgument("when") { type = NavType.StringType },)
|
||||
) {
|
||||
Schedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.ui.theme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
package ru.sweetbread.unn.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 790 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 878 B After Width: | Height: | Size: 942 B |
|
Before Width: | Height: | Size: 534 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.7 KiB |
@@ -1,5 +1,3 @@
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
<dimen name="activity_horizontal_margin">48dp</dimen>
|
||||
</resources>
|
||||
@@ -1,35 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
<string name="app_name_reg">ННГУ</string>
|
||||
<string name="app_name_dev">ННГУ Альфа</string>
|
||||
<string name="app_name_beta">ННГУ Бета</string>
|
||||
<string name="prompt_password">Пароль</string>
|
||||
<string name="prompt_login">Логин</string>
|
||||
<string name="sign_in">Войти</string>
|
||||
<string name="assistant">Ассистент</string>
|
||||
<string name="lecturer">Преподаватель</string>
|
||||
<string name="slecturer">Старший Преподаватель</string>
|
||||
<string name="aprofessor">Доцент</string>
|
||||
<string name="auditorium">Аудитория</string>
|
||||
<string name="building">Здание</string>
|
||||
<string name="floor">Этаж</string>
|
||||
<string name="noData">Нет данных</string>
|
||||
<string name="news">Новости</string>
|
||||
<string name="schedule">Расписание</string>
|
||||
<string name="record_book">Зачётная книга</string>
|
||||
<string name="about_app">О приложении</string>
|
||||
<string name="documents">Документы</string>
|
||||
<string name="materials">Материалы</string>
|
||||
<string-array name="short_weekdays">
|
||||
<item>Пн</item>
|
||||
<item>Вт</item>
|
||||
<item>Ср</item>
|
||||
<item>Чт</item>
|
||||
<item>Пт</item>
|
||||
<item>Сб</item>
|
||||
</string-array>
|
||||
<string name="student">Студент</string>
|
||||
<string name="employee">Сотрудник</string>
|
||||
</resources>
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
<dimen name="activity_horizontal_margin">200dp</dimen>
|
||||
</resources>
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
<dimen name="activity_horizontal_margin">48dp</dimen>
|
||||
</resources>
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
<!-- Default screen margins, per the Android Design guidelines. -->
|
||||
<dimen name="activity_horizontal_margin">16dp</dimen>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1565AA</color>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -1,37 +1,9 @@
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
<string name="app_name_reg">UNN</string>
|
||||
<string name="app_name_dev">UNN Dev</string>
|
||||
<string name="app_name_beta">UNN Beta</string>
|
||||
<string name="app_name" translatable="false">UNN</string>
|
||||
<!-- <string name="title_activity_login">LoginActivity</string>-->
|
||||
<string name="prompt_email" translatable="false">Email</string>
|
||||
<string name="prompt_login">Login</string>
|
||||
<string name="prompt_password">Password</string>
|
||||
<string name="sign_in">Sign in</string>
|
||||
<string name="assistant">Assistant</string>
|
||||
<string name="lecturer">Lecturer</string>
|
||||
<string name="slecturer">Senior Lecturer</string>
|
||||
<string name="aprofessor">Assistant professor</string>
|
||||
<string name="auditorium">Auditorium</string>
|
||||
<string name="building">Building</string>
|
||||
<string name="floor">Floor</string>
|
||||
<string name="lorem" translatable="false">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vel iaculis elit. Aliquam varius urna ut nisl rhoncus ullamcorper. Maecenas et nisl at dui mollis maximus nec in libero. Ut eu nulla id felis hendrerit lobortis. Maecenas vel facilisis lectus. Morbi eleifend massa a ante consequat, eu aliquam elit euismod. Aenean quis erat tincidunt, egestas ligula id, convallis tortor. Vivamus volutpat condimentum nisl sed eleifend. Aenean dapibus dolor ut orci lobortis, placerat lobortis tortor pretium. Nam eros lectus, convallis sed ultricies sit amet, lacinia sed sem. In mi odio, porta non malesuada et, cursus a metus. Morbi quis odio sed quam commodo gravida id sit amet dolor. Donec ac iaculis massa. Nulla mauris sapien, auctor consequat est in, tempus accumsan ipsum. Donec semper volutpat nisi. Quisque dignissim tellus ipsum, sed malesuada libero aliquam sed. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam eleifend pharetra orci eu scelerisque. In hac habitasse platea dictumst. Sed non neque vitae metus porttitor vestibulum ut eget felis. Aliquam venenatis a magna eu mattis. Proin rutrum, sapien id viverra finibus, nisi quam aliquam eros, et dignissim lectus sem sit amet purus. Donec et semper enim, sed pretium lacus. Nullam venenatis ullamcorper maximus. Mauris pellentesque velit non sem sollicitudin molestie. Duis hendrerit consequat enim eget euismod.</string>
|
||||
<string name="noData">No Data</string>
|
||||
<string name="news">News</string>
|
||||
<string name="schedule">Schedule</string>
|
||||
<string name="student">Student</string>
|
||||
<string name="employee">Employee</string>
|
||||
<string name="record_book">Record book</string>
|
||||
<string name="about_app">About app</string>
|
||||
<string name="documents">Documents</string>
|
||||
<string name="materials">Materials</string>
|
||||
<string name="developer">Developer</string>
|
||||
<string-array name="short_weekdays">
|
||||
<item>Mn</item>
|
||||
<item>Tu</item>
|
||||
<item>We</item>
|
||||
<item>Th</item>
|
||||
<item>Fr</item>
|
||||
<item>Sa</item>
|
||||
</string-array>
|
||||
<!-- <string name="login_failed">"Login failed"</string>-->
|
||||
</resources>
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<resources>
|
||||
|
||||
<style name="Theme.UNN" parent="android:Theme.Material.NoActionBar" />
|
||||
<style name="Theme.UNN" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) 2025 Gleb Zaharov. License: GPLv3 (see LICENSE). -->
|
||||
|
||||
<!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.sentry.android) apply false
|
||||
alias(libs.plugins.androidApplication) apply false
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid) apply false
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
# Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -1,43 +1,36 @@
|
||||
[versions]
|
||||
agp = "9.3.2"
|
||||
coil = "3.6.2"
|
||||
coreSplashscreen = "1.2.0"
|
||||
datastorePreferences = "1.2.1"
|
||||
desugarJdkLibs = "2.1.5"
|
||||
kotlin = "2.4.20"
|
||||
ksp = "2.3.11"
|
||||
coreKtx = "1.19.0"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
ktor = "3.5.2"
|
||||
lifecycle = "2.11.0"
|
||||
activity = "1.13.0"
|
||||
composeBom = "2026.08.00"
|
||||
navigationCompose = "2.10.0"
|
||||
room = "2.8.4"
|
||||
sentryAndroid = "8.55.0"
|
||||
sentryGradlePlugin = "6.19.0"
|
||||
splitties = "3.0.0"
|
||||
paging = "3.5.1"
|
||||
agp = "8.3.0"
|
||||
datastorePreferences = "1.0.0"
|
||||
kotlin = "1.9.0"
|
||||
coreKtx = "1.10.1"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.1.5"
|
||||
espressoCore = "3.5.1"
|
||||
ktorClientCio = "2.3.9"
|
||||
ktorClientCore = "2.3.9"
|
||||
ktorClientLogging = "2.3.9"
|
||||
lifecycleRuntimeKtx = "2.6.1"
|
||||
activityCompose = "1.7.0"
|
||||
composeBom = "2023.08.00"
|
||||
appcompat = "1.6.1"
|
||||
material = "1.10.0"
|
||||
annotation = "1.6.0"
|
||||
constraintlayout = "2.1.4"
|
||||
lifecycleLivedataKtx = "2.6.1"
|
||||
lifecycleViewmodelKtx = "2.6.1"
|
||||
activity = "1.8.0"
|
||||
navigationCompose = "2.7.7"
|
||||
splittiesFunPackAndroidBaseWithViewsDsl = "3.0.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
|
||||
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
@@ -46,20 +39,19 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
|
||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||
sentry = { module = "io.sentry:sentry-android", version.ref = "sentryAndroid" }
|
||||
splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl", version.ref = "splitties" }
|
||||
splitties-room = { module = "com.louiscad.splitties:splitties-arch-room", version.ref = "splitties" }
|
||||
androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" }
|
||||
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
|
||||
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
|
||||
androidx-paging-room = { module = "androidx.room:room-paging", version.ref = "room" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktorClientCio" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" }
|
||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorClientLogging" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" }
|
||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" }
|
||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
|
||||
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||
splitties-fun-pack-android-base-with-views-dsl = { module = "com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl", version.ref = "splittiesFunPackAndroidBaseWithViewsDsl" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
sentry-android = { id = "io.sentry.android.gradle", version.ref = "sentryGradlePlugin" }
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
|
||||
|
||||
#Sat Mar 16 18:30:45 MSK 2024
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||