3.0.0 / 3.0.1

This commit is contained in:
2025-01-28 20:45:45 +04:00
parent 44c1f01541
commit 0ab25e68a3
157 changed files with 5275 additions and 4450 deletions

1
.idea/.name generated
View File

@@ -1 +0,0 @@
PolytecnicNext

View File

@@ -1,9 +1,5 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" /> <option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions> <indentOptions>
@@ -117,8 +113,5 @@
</rules> </rules>
</arrangement> </arrangement>
</codeStyleSettings> </codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme> </code_scheme>
</component> </component>

View File

@@ -1,5 +1,5 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<state> <state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" /> <option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state> </state>
</component> </component>

6
.idea/compiler.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View File

@@ -2,16 +2,8 @@
<project version="4"> <project version="4">
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="Polytechnic.app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-10-10T18:25:08.106861775Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=482a22d" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

View File

@@ -1,3 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="n08i40k" />
</component>

14
.idea/discord.xml generated
View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>

3
.idea/kotlinc.xml generated
View File

@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="Kotlin2JsCompilerArguments">
<option name="moduleKind" value="plain" />
</component>
<component name="Kotlin2JvmCompilerArguments"> <component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" /> <option name="jvmTarget" value="1.8" />
</component> </component>

7
.idea/misc.xml generated
View File

@@ -1,11 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="EntryPointsManager">
<list size="2">
<item index="0" class="java.lang.String" itemvalue="dagger.Module" />
<item index="1" class="java.lang.String" itemvalue="dagger.Provides" />
</list>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 Nikita
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,11 +1,11 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import com.google.protobuf.gradle.id import com.google.protobuf.gradle.id
import com.google.protobuf.gradle.proto import com.google.protobuf.gradle.proto
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.0.20" kotlin("plugin.serialization") version "2.0.20"
id("kotlin-parcelize") id("kotlin-parcelize")
@@ -18,9 +18,22 @@ plugins {
alias(libs.plugins.google.firebase.crashlytics) alias(libs.plugins.google.firebase.crashlytics)
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
id("vkid.manifest.placeholders") version "1.1.0" apply true
} }
val localProperties = gradleLocalProperties(rootDir, providers)
android { android {
signingConfigs {
create("release") {
storeFile = file(localProperties.getProperty("sign.storeFile"))
keyAlias = localProperties.getProperty("sign.keyAlias")
storePassword = localProperties.getProperty("sign.storePassword")
keyPassword = localProperties.getProperty("sign.keyPassword")
}
}
namespace = "ru.n08i40k.polytechnic.next" namespace = "ru.n08i40k.polytechnic.next"
compileSdk = 35 compileSdk = 35
@@ -33,13 +46,10 @@ android {
applicationId = "ru.n08i40k.polytechnic.next" applicationId = "ru.n08i40k.polytechnic.next"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 23 versionCode = 25
versionName = "2.3.0" versionName = "3.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
} }
buildTypes { buildTypes {
@@ -49,11 +59,14 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs.getByName("release")
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "11"
@@ -64,11 +77,6 @@ android {
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.1" kotlinCompilerExtensionVersion = "1.5.1"
} }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
sourceSets { sourceSets {
getByName("main") { getByName("main") {
@@ -90,6 +98,15 @@ android {
} }
dependencies { dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
// vk
implementation(libs.vk.vkid)
implementation(libs.vk.onetap.compose)
// internet
implementation(libs.volley)
// work manager // work manager
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
@@ -116,6 +133,7 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
// default
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@@ -124,13 +142,10 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.volley)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
// test
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View File

@@ -19,3 +19,26 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
-renamesourcefileattribute SourceFile -renamesourcefileattribute SourceFile
#noinspection ShrinkerUnresolvedReference
-keep class com.vk.dto.common.id.UserId { *; }
-keep class * extends com.vk.id.sample.app.util.carrying.CarryingCallable { *; }
-keep class android.content.Context { *; }
# OneTapStyle.Companion methods's arguments and return types
-keep class com.vk.id.onetap.common.OneTapStyle { *; }
-keep class com.vk.id.onetap.common.OneTapStyle$* { *; }
-keep class com.vk.id.onetap.common.button.style.OneTapButtonCornersStyle { *; }
-keep class com.vk.id.onetap.common.button.style.OneTapButtonSizeStyle { *; }
-keep class com.vk.id.onetap.common.button.style.OneTapButtonElevationStyle { *; }
# OAuthListWidgetStyle.Companion methods's arguments and return types
-keep class com.vk.id.multibranding.common.style.OAuthListWidgetStyle { *; }
-keep class com.vk.id.multibranding.common.style.OAuthListWidgetStyle$* { *; }
-keep class com.vk.id.multibranding.common.style.OAuthListWidgetCornersStyle { *; }
-keep class com.vk.id.multibranding.common.style.OAuthListWidgetSizeStyle { *; }
# OneTapBottomSheetStyle.Companion methods's arguments and return types
-keep class com.vk.id.onetap.compose.onetap.sheet.style.OneTapBottomSheetStyle { *; }
-keep class com.vk.id.onetap.compose.onetap.sheet.style.OneTapBottomSheetStyle$* { *; }
-keep class com.vk.id.onetap.compose.onetap.sheet.style.OneTapSheetCornersStyle { *; }
-keep class com.vk.id.onetap.common.button.style.OneTapButtonCornersStyle { *; }
-keep class com.vk.id.onetap.common.button.style.OneTapButtonSizeStyle { *; }

View File

@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("ru.n08i40k.polytecnic.next", appContext.packageName) assertEquals("ru.n08i40k.polytechnic.next", appContext.packageName)
} }
} }

View File

@@ -2,48 +2,40 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- bruh --> <!-- чтооооо не может быть, мне нужен интернет? правда? -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- For posting notifications from FCM and CLV services --> <!-- нихуя себе что это такое -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- For CLV service able to work --> <!-- ну это по приколу добавил конечно же -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application <application
android:name=".PolytechnicApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:name=".Application"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.PolytechnicNext" android:supportsRtl="true"
android:theme="@style/Theme.Polytechnic"
tools:targetApi="35"> tools:targetApi="35">
<service <service
android:name=".service.MyFirebaseMessagingService" android:name=".service.FCMService"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" /> <action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".service.CurrentLessonViewService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Service for viewing current lesson in notification." />
</service>
<activity <activity
android:name=".ui.MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.PolytechnicNext"> android:theme="@style/Theme.Polytechnic">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,89 @@
package ru.n08i40k.polytechnic.next
import android.app.Application
import com.google.android.gms.tasks.OnCompleteListener
import com.google.android.gms.tasks.Task
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.remoteconfig.ConfigUpdate
import com.google.firebase.remoteconfig.ConfigUpdateListener
import com.google.firebase.remoteconfig.FirebaseRemoteConfigException
import com.google.firebase.remoteconfig.remoteConfigSettings
import com.vk.id.VKID
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.utils.Observable
import ru.n08i40k.polytechnic.next.worker.UpdateFCMTokenWorker
import ru.n08i40k.polytechnic.next.worker.UpdateLinkWorker
import java.util.logging.Logger
import javax.inject.Inject
data class AppEvents(
val signOut: Observable<Unit> = Observable<Unit>()
)
@HiltAndroidApp
class Application : Application() {
@Inject
lateinit var container: AppContainer
val events = AppEvents()
val version
get() = applicationContext.packageManager
.getPackageInfo(this.packageName, 0)
.versionName!!
// val version
// get() = "2.0.2"
private fun scheduleUpdateLinkWorker() {
container.remoteConfig.activate().addOnCompleteListener {
UpdateLinkWorker.schedule(this@Application)
}
}
private fun fixupToken() {
if (runBlocking { settings.data.map { it.fcmToken }.first() }.isNotEmpty())
return
FirebaseMessaging.getInstance().token.addOnCompleteListener(object :
OnCompleteListener<String> {
override fun onComplete(token: Task<String?>) {
if (!token.isSuccessful)
return
UpdateFCMTokenWorker.schedule(applicationContext, token.result!!)
}
})
}
override fun onCreate() {
super.onCreate()
VKID.init(this)
val remoteConfig = container.remoteConfig
remoteConfig.setConfigSettingsAsync(remoteConfigSettings {
minimumFetchIntervalInSeconds = 3600
})
remoteConfig.setDefaultsAsync(R.xml.remote_config_defaults)
remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener {
override fun onUpdate(configUpdate: ConfigUpdate) {
scheduleUpdateLinkWorker()
}
override fun onError(error: FirebaseRemoteConfigException) {
Logger.getLogger("Application")
.severe("Failed to fetch RemoteConfig update!")
}
})
scheduleUpdateLinkWorker()
fixupToken()
}
}

View File

@@ -0,0 +1,116 @@
package ru.n08i40k.polytechnic.next
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.app.NotificationChannels
import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.ui.PolytechnicApp
import ru.n08i40k.polytechnic.next.ui.theme.AppTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private fun createNotificationChannel(
notificationManager: NotificationManager,
name: String,
description: String,
channelId: String
) {
val channel = NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_DEFAULT)
channel.description = description
notificationManager.createNotificationChannel(channel)
}
private fun createNotificationChannels() {
if (!hasNotificationPermission())
return
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel(
notificationManager,
getString(R.string.schedule_channel_name),
getString(R.string.schedule_channel_description),
NotificationChannels.SCHEDULE_UPDATE
)
createNotificationChannel(
notificationManager,
getString(R.string.app_update_channel_name),
getString(R.string.app_update_channel_description),
NotificationChannels.APP_UPDATE
)
// createNotificationChannel(
// notificationManager,
// getString(R.string.lesson_view_channel_name),
// getString(R.string.lesson_view_channel_description),
// NotificationChannels.LESSON_VIEW
// )
}
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) createNotificationChannels()
}
private fun askNotificationPermission() {
if (hasNotificationPermission())
return
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
private fun hasNotificationPermission(): Boolean =
(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|| ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
askNotificationPermission()
createNotificationChannels()
lifecycleScope.launch {
settings.data.first()
}
setContent {
AppTheme {
Surface {
Box(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
) {
PolytechnicApp()
}
}
}
}
}
}

View File

@@ -1,9 +0,0 @@
package ru.n08i40k.polytechnic.next
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import ru.n08i40k.polytechnic.next.data.AppContainer
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(val appContainer: AppContainer) : ViewModel()

View File

@@ -1,28 +0,0 @@
package ru.n08i40k.polytechnic.next
import android.Manifest
import android.app.Application
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import dagger.hilt.android.HiltAndroidApp
import ru.n08i40k.polytechnic.next.data.AppContainer
import javax.inject.Inject
@HiltAndroidApp
class PolytechnicApplication : Application() {
@Inject
lateinit var container: AppContainer
fun getAppVersion(): String {
return applicationContext.packageManager
.getPackageInfo(this.packageName, 0)
.versionName!!
}
fun hasNotificationPermission(): Boolean {
return (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|| ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED)
}
}

View File

@@ -0,0 +1,63 @@
package ru.n08i40k.polytechnic.next.app
import android.app.Application
import android.content.Context
import com.google.firebase.Firebase
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.remoteConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.n08i40k.polytechnic.next.repository.cache.NetworkCacheRepository
import ru.n08i40k.polytechnic.next.repository.cache.impl.LocalNetworkCacheRepository
import ru.n08i40k.polytechnic.next.repository.cache.impl.MockNetworkCacheRepository
import ru.n08i40k.polytechnic.next.repository.profile.ProfileRepository
import ru.n08i40k.polytechnic.next.repository.profile.impl.MockProfileRepository
import ru.n08i40k.polytechnic.next.repository.profile.impl.RemoteProfileRepository
import ru.n08i40k.polytechnic.next.repository.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.repository.schedule.impl.MockScheduleRepository
import ru.n08i40k.polytechnic.next.repository.schedule.impl.RemoteScheduleRepository
import javax.inject.Singleton
interface AppContainer {
val context: Context
val remoteConfig: FirebaseRemoteConfig
val profileRepository: ProfileRepository
val scheduleRepository: ScheduleRepository
val networkCacheRepository: NetworkCacheRepository
}
abstract class SharedAppContainer(override val context: Context) : AppContainer {
override val remoteConfig: FirebaseRemoteConfig by lazy { Firebase.remoteConfig }
}
@Suppress("unused")
class MockAppContainer(context: Context) : SharedAppContainer(context) {
override val profileRepository by lazy { MockProfileRepository() }
override val scheduleRepository by lazy { MockScheduleRepository() }
override val networkCacheRepository by lazy { MockNetworkCacheRepository() }
}
@Suppress("unused")
class RemoteAppContainer(context: Context) : SharedAppContainer(context) {
override val profileRepository by lazy { RemoteProfileRepository(this) }
override val scheduleRepository by lazy { RemoteScheduleRepository(this) }
override val networkCacheRepository by lazy { LocalNetworkCacheRepository(this) }
}
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideAppContainer(application: Application): AppContainer {
return RemoteAppContainer(application.applicationContext)
}
}
val Context.appContainer
get() =
(this.applicationContext as ru.n08i40k.polytechnic.next.Application).container

View File

@@ -1,7 +1,6 @@
package ru.n08i40k.polytechnic.next package ru.n08i40k.polytechnic.next.app
object NotificationChannels { object NotificationChannels {
const val LESSON_VIEW = "lesson-view"
const val SCHEDULE_UPDATE = "schedule-update" const val SCHEDULE_UPDATE = "schedule-update"
const val APP_UPDATE = "app-update" const val APP_UPDATE = "app-update"
} }

View File

@@ -1,82 +0,0 @@
package ru.n08i40k.polytechnic.next.data
import android.app.Application
import android.content.Context
import com.google.firebase.Firebase
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.remoteConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.n08i40k.polytechnic.next.data.cache.NetworkCacheRepository
import ru.n08i40k.polytechnic.next.data.cache.impl.FakeNetworkCacheRepository
import ru.n08i40k.polytechnic.next.data.cache.impl.LocalNetworkCacheRepository
import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository
import ru.n08i40k.polytechnic.next.data.schedule.impl.RemoteScheduleRepository
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.ScheduleReplacerRepository
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.impl.FakeScheduleReplacerRepository
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.impl.RemoteScheduleReplacerRepository
import ru.n08i40k.polytechnic.next.data.users.ProfileRepository
import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
import ru.n08i40k.polytechnic.next.data.users.impl.RemoteProfileRepository
import javax.inject.Singleton
interface AppContainer {
val applicationContext: Context
val networkCacheRepository: NetworkCacheRepository
val scheduleRepository: ScheduleRepository
val scheduleReplacerRepository: ScheduleReplacerRepository
val profileRepository: ProfileRepository
val remoteConfig: FirebaseRemoteConfig
}
class MockAppContainer(override val applicationContext: Context) : AppContainer {
override val networkCacheRepository: NetworkCacheRepository
by lazy { FakeNetworkCacheRepository() }
override val scheduleRepository: ScheduleRepository
by lazy { FakeScheduleRepository() }
override val scheduleReplacerRepository: ScheduleReplacerRepository
by lazy { FakeScheduleReplacerRepository() }
override val profileRepository: ProfileRepository
by lazy { FakeProfileRepository() }
override val remoteConfig: FirebaseRemoteConfig
by lazy { Firebase.remoteConfig }
}
class RemoteAppContainer(override val applicationContext: Context) : AppContainer {
override val networkCacheRepository: NetworkCacheRepository
by lazy { LocalNetworkCacheRepository(applicationContext) }
override val scheduleRepository: ScheduleRepository
by lazy { RemoteScheduleRepository(applicationContext) }
override val scheduleReplacerRepository: ScheduleReplacerRepository
by lazy { RemoteScheduleReplacerRepository(applicationContext) }
override val profileRepository: ProfileRepository
by lazy { RemoteProfileRepository(applicationContext) }
override val remoteConfig: FirebaseRemoteConfig
by lazy { Firebase.remoteConfig }
}
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideAppContainer(application: Application): AppContainer {
return RemoteAppContainer(application.applicationContext)
}
}

View File

@@ -1,6 +0,0 @@
package ru.n08i40k.polytechnic.next.data
sealed interface MyResult<out R> {
data class Success<out T>(val data: T) : MyResult<T>
data class Failure(val exception: Exception) : MyResult<Nothing>
}

View File

@@ -1,16 +0,0 @@
package ru.n08i40k.polytechnic.next.data.scheduleReplacer
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
interface ScheduleReplacerRepository {
suspend fun getAll(): MyResult<List<ScheduleReplacer>>
suspend fun setCurrent(
fileName: String,
fileData: ByteArray,
fileType: String
): MyResult<Unit>
suspend fun clear(): MyResult<Int>
}

View File

@@ -1,31 +0,0 @@
package ru.n08i40k.polytechnic.next.data.scheduleReplacer.impl
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.ScheduleReplacerRepository
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
class FakeScheduleReplacerRepository : ScheduleReplacerRepository {
companion object {
@Suppress("SpellCheckingInspection")
val exampleReplacers: List<ScheduleReplacer> = listOf(
ScheduleReplacer("test-etag", 236 * 1024),
ScheduleReplacer("frgsjkfhg", 623 * 1024),
)
}
override suspend fun getAll(): MyResult<List<ScheduleReplacer>> {
return MyResult.Success(exampleReplacers)
}
override suspend fun setCurrent(
fileName: String,
fileData: ByteArray,
fileType: String
): MyResult<Unit> {
return MyResult.Success(Unit)
}
override suspend fun clear(): MyResult<Int> {
return MyResult.Success(1)
}
}

View File

@@ -1,41 +0,0 @@
package ru.n08i40k.polytechnic.next.data.scheduleReplacer.impl
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.ScheduleReplacerRepository
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
import ru.n08i40k.polytechnic.next.network.request.scheduleReplacer.ScheduleReplacerClear
import ru.n08i40k.polytechnic.next.network.request.scheduleReplacer.ScheduleReplacerGet
import ru.n08i40k.polytechnic.next.network.request.scheduleReplacer.ScheduleReplacerSet
import ru.n08i40k.polytechnic.next.network.tryFuture
class RemoteScheduleReplacerRepository(private val context: Context) : ScheduleReplacerRepository {
override suspend fun getAll(): MyResult<List<ScheduleReplacer>> =
withContext(Dispatchers.IO) {
tryFuture { ScheduleReplacerGet(context, it, it) }
}
override suspend fun setCurrent(
fileName: String,
fileData: ByteArray,
fileType: String
): MyResult<Nothing> =
withContext(Dispatchers.IO) {
tryFuture { ScheduleReplacerSet(context, fileName, fileData, fileType, it, it) }
}
override suspend fun clear(): MyResult<Int> {
val response = withContext(Dispatchers.IO) {
tryFuture { ScheduleReplacerClear(context, it, it) }
}
return when (response) {
is MyResult.Failure -> response
is MyResult.Success -> MyResult.Success(response.data.count)
}
}
}

View File

@@ -1,10 +0,0 @@
package ru.n08i40k.polytechnic.next.data.users
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.Profile
interface ProfileRepository {
suspend fun getProfile(): MyResult<Profile>
suspend fun setFcmToken(token: String): MyResult<Unit>
}

View File

@@ -1,39 +0,0 @@
package ru.n08i40k.polytechnic.next.data.users.impl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.data.users.ProfileRepository
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.model.UserRole
class FakeProfileRepository : ProfileRepository {
private var counter = 0
companion object {
val exampleProfile =
Profile(
"66db32d24030a07e02d974c5",
"128735612876",
"n08i40k",
"ИС-214/23",
UserRole.STUDENT
)
}
override suspend fun getProfile(): MyResult<Profile> {
return withContext(Dispatchers.IO) {
delay(1500)
if (counter++ % 3 == 0)
MyResult.Failure(Exception())
else
MyResult.Success(exampleProfile)
}
}
override suspend fun setFcmToken(token: String): MyResult<Unit> {
return MyResult.Success(Unit)
}
}

View File

@@ -1,23 +0,0 @@
package ru.n08i40k.polytechnic.next.data.users.impl
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.data.users.ProfileRepository
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.network.request.fcm.FcmSetToken
import ru.n08i40k.polytechnic.next.network.request.profile.ProfileMe
import ru.n08i40k.polytechnic.next.network.tryFuture
class RemoteProfileRepository(private val context: Context) : ProfileRepository {
override suspend fun getProfile(): MyResult<Profile> =
withContext(Dispatchers.IO) {
tryFuture { ProfileMe(context, it, it) }
}
override suspend fun setFcmToken(token: String): MyResult<Unit> =
withContext(Dispatchers.IO) {
tryFuture { FcmSetToken(context, token, it, it) }
}
}

View File

@@ -26,8 +26,8 @@ data class Day(
val street: String? = null val street: String? = null
) : Parcelable { ) : Parcelable {
constructor(name: String, date: Instant, lessons: List<Lesson>) : this( constructor(name: String, date: Instant, lessons: List<Lesson>, street: String?) : this(
name, date.toEpochMilliseconds(), lessons name, date.toEpochMilliseconds(), lessons, street
) )
val date: Instant val date: Instant

View File

@@ -3,6 +3,7 @@ package ru.n08i40k.polytechnic.next.model
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import ru.n08i40k.polytechnic.next.utils.dateTime
import java.util.Calendar import java.util.Calendar
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
@@ -14,12 +15,16 @@ data class GroupOrTeacher(
) : Parcelable { ) : Parcelable {
val currentIdx: Int? val currentIdx: Int?
get() { get() {
val currentDay = (Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 2) val currentDay = (Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 2) + 1
if (currentDay < 0 || currentDay > days.size - 1) val day = days.filter {
it.date.dateTime.date.dayOfWeek.value == currentDay
}
if (day.isEmpty())
return null return null
return currentDay return days.indexOf(day[0])
} }
val current: Day? val current: Day?
@@ -27,6 +32,8 @@ data class GroupOrTeacher(
return days.getOrNull(currentIdx ?: return null) return days.getOrNull(currentIdx ?: return null)
} }
// TODO: вернуть
@Suppress("unused")
val currentKV: Pair<Int, Day>? val currentKV: Pair<Int, Day>?
get() { get() {
val idx = currentIdx ?: return null val idx = currentIdx ?: return null

View File

@@ -18,42 +18,32 @@ data class Lesson(
val group: String? = null, val group: String? = null,
val subGroups: List<SubGroup> val subGroups: List<SubGroup>
) : Parcelable { ) : Parcelable {
val duration: Int // TODO: вернуть
get() { @Suppress("unused")
val startMinutes = time.start.dayMinutes val duration get() = time.end.dayMinutes - time.start.dayMinutes
val endMinutes = time.end.dayMinutes
return endMinutes - startMinutes
}
@Suppress("unused")
fun getNameAndCabinetsShort(context: Context): String { fun getNameAndCabinetsShort(context: Context): String {
val name = val name =
if (type == LessonType.BREAK) context.getString(R.string.lesson_break) if (type == LessonType.BREAK) context.getString(
if (group == null)
R.string.student_break
else
R.string.teacher_break
)
else this.name else this.name
val limitedName = name!! limit 15 val shortName = name!! limit 15
val cabinetList = subGroups.map { it.cabinet }
val cabinets = subGroups.map { it.cabinet } if (cabinetList.isEmpty())
return shortName
if (cabinets.isEmpty()) if (cabinetList.size == 1 && cabinetList[0] == "с")
return limitedName return "$shortName ${context.getString(R.string.in_gym_lc)}"
if (cabinets.size == 1 && cabinets[0] == "с") val cabinets =
return buildString { context.getString(R.string.in_cabinets_short_lc, cabinetList.joinToString(", "))
append(limitedName) return "$shortName $cabinets"
append(" ")
append(context.getString(R.string.in_gym_lc))
}
return buildString {
append(limitedName)
append(" ")
append(
context.getString(
R.string.in_cabinets_short_lc,
cabinets.joinToString(", ")
)
)
}
} }
} }

View File

@@ -5,8 +5,9 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Profile( data class Profile(
val id: String, val id: String,
val accessToken: String,
val username: String, val username: String,
val group: String, val group: String,
val role: UserRole val role: UserRole,
val accessToken: String? = null,
val vkId: Int? = null
) )

View File

@@ -1,5 +1,5 @@
package ru.n08i40k.polytechnic.next.network package ru.n08i40k.polytechnic.next.network
object NetworkValues { object NetworkValues {
const val API_HOST = "https://polytechnic.n08i40k.ru:5050/api/" const val API_HOST = "https://polytechnic.n08i40k.ru/api/"
} }

View File

@@ -6,13 +6,12 @@ import com.android.volley.toolbox.StringRequest
import java.util.logging.Logger import java.util.logging.Logger
open class RequestBase( open class RequestBase(
protected val context: Context,
method: Int, method: Int,
url: String?, url: String?,
listener: Response.Listener<String>, listener: Response.Listener<String>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : StringRequest(method, NetworkValues.API_HOST + url, listener, errorListener) { ) : StringRequest(method, NetworkValues.API_HOST + url, listener, errorListener) {
open fun send() { open fun send(context: Context) {
Logger.getLogger("RequestBase").info("Sending request to $url") Logger.getLogger("RequestBase").info("Sending request to $url")
NetworkConnection.getInstance(context).addToRequestQueue(this) NetworkConnection.getInstance(context).addToRequestQueue(this)
} }

View File

@@ -1,17 +1,19 @@
package ru.n08i40k.polytechnic.next.network package ru.n08i40k.polytechnic.next.network
import android.content.Context
import com.android.volley.VolleyError import com.android.volley.VolleyError
import com.android.volley.toolbox.RequestFuture import com.android.volley.toolbox.RequestFuture
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.utils.MyResult
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeoutException import java.util.concurrent.TimeoutException
fun <ResultT, RequestT : RequestBase> tryFuture( fun <ResultT, RequestT : RequestBase> tryFuture(
context: Context,
buildRequest: (RequestFuture<ResultT>) -> RequestT buildRequest: (RequestFuture<ResultT>) -> RequestT
): MyResult<ResultT> { ): MyResult<ResultT> {
val future = RequestFuture.newFuture<ResultT>() val future = RequestFuture.newFuture<ResultT>()
buildRequest(future).send() buildRequest(future).send(context)
return tryGet(future) return tryGet(future)
} }

View File

@@ -1,8 +1,7 @@
package ru.n08i40k.polytechnic.next.network package ru.n08i40k.polytechnic.next.network.request
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.app.AppContainer
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.DataOutputStream import java.io.DataOutputStream
@@ -11,13 +10,13 @@ import java.io.UnsupportedEncodingException
import kotlin.math.min import kotlin.math.min
open class AuthorizedMultipartRequest( open class AuthorizedMultipartRequest(
context: Context, appContainer: AppContainer,
method: Int, method: Int,
url: String, url: String,
listener: Response.Listener<String>, listener: Response.Listener<String>,
errorListener: Response.ErrorListener?, errorListener: Response.ErrorListener?,
canBeUnauthorized: Boolean = false canBeUnauthorized: Boolean = false
) : AuthorizedRequest(context, method, url, listener, errorListener, canBeUnauthorized) { ) : AuthorizedRequest(appContainer, method, url, listener, errorListener, canBeUnauthorized) {
private val twoHyphens = "--" private val twoHyphens = "--"
private val lineEnd = "\r\n" private val lineEnd = "\r\n"
private val boundary = "apiclient-" + System.currentTimeMillis() private val boundary = "apiclient-" + System.currentTimeMillis()

View File

@@ -1,52 +1,72 @@
package ru.n08i40k.polytechnic.next.network.request package ru.n08i40k.polytechnic.next.network.request
import android.content.Context
import com.android.volley.AuthFailureError import com.android.volley.AuthFailureError
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.VolleyError
import jakarta.inject.Singleton
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.RequestBase import ru.n08i40k.polytechnic.next.network.RequestBase
import ru.n08i40k.polytechnic.next.settings.settingsDataStore import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
open class AuthorizedRequest( open class AuthorizedRequest(
context: Context, val appContainer: AppContainer,
method: Int, method: Int,
url: String, url: String,
listener: Response.Listener<String>, listener: Response.Listener<String>,
errorListener: Response.ErrorListener?, errorListener: Response.ErrorListener?,
private val canBeUnauthorized: Boolean = false private val canBeUnauthorized: Boolean = false,
) : RequestBase( ) : RequestBase(
context,
method, method,
url, url,
listener, listener,
Response.ErrorListener { @Singleton
if (!canBeUnauthorized && it is AuthFailureError) { object : Response.ErrorListener {
override fun onErrorResponse(error: VolleyError?) {
val context = appContainer.context
if (!canBeUnauthorized && error is AuthFailureError) {
runBlocking { runBlocking {
context.settingsDataStore.updateData { currentSettings -> context.settings.updateData { currentSettings ->
currentSettings.toBuilder().setUserId("") currentSettings
.setAccessToken("").build() .toBuilder()
.clear()
.build()
} }
} }
if (context.profileViewModel != null)
context.profileViewModel!!.onUnauthorized()
}
errorListener?.onErrorResponse(it) // TODO: если не авторизован
// if (context.profileViewModel != null)
// context.profileViewModel!!.onUnauthorized()
}
runBlocking { appContainer.profileRepository.signOut() }
errorListener?.onErrorResponse(error)
}
}) { }) {
override fun getHeaders(): MutableMap<String, String> { override fun getHeaders(): MutableMap<String, String> {
val accessToken = runBlocking { val accessToken = runBlocking {
context.settingsDataStore.data.map { settings -> settings.accessToken }.first() appContainer.context
.settings
.data
.map { settings -> settings.accessToken }
.first()
} }
if (accessToken.isEmpty() && context.profileViewModel != null) // TODO: если не авторизован
context.profileViewModel!!.onUnauthorized() // if (accessToken.isEmpty() && context.profileViewModel != null)
// context.profileViewModel!!.onUnauthorized()
val headers = super.getHeaders() val headers = super.getHeaders()
headers["Authorization"] = "Bearer $accessToken" headers["Authorization"] = "Bearer $accessToken"
return headers return headers
} }
val appContext get() = appContainer.context
} }

View File

@@ -7,31 +7,36 @@ import com.android.volley.toolbox.StringRequest
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.PolytechnicApplication import ru.n08i40k.polytechnic.next.Application
import ru.n08i40k.polytechnic.next.data.AppContainer import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.app.appContainer
import ru.n08i40k.polytechnic.next.network.NetworkConnection import ru.n08i40k.polytechnic.next.network.NetworkConnection
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetCacheStatus import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetCacheStatus
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleUpdate import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleUpdate
import ru.n08i40k.polytechnic.next.network.tryFuture import ru.n08i40k.polytechnic.next.network.tryFuture
import ru.n08i40k.polytechnic.next.network.tryGet import ru.n08i40k.polytechnic.next.network.tryGet
import ru.n08i40k.polytechnic.next.utils.MyResult
import java.util.logging.Logger import java.util.logging.Logger
import java.util.regex.Pattern import java.util.regex.Pattern
open class CachedRequest( open class CachedRequest(
context: Context, appContainer: AppContainer,
method: Int, method: Int,
private val url: String, private val url: String,
private val listener: Response.Listener<String>, private val listener: Response.Listener<String>,
errorListener: Response.ErrorListener?, errorListener: Response.ErrorListener?,
) : AuthorizedRequest(context, method, url, { ) : AuthorizedRequest(
appContainer,
method,
url,
{
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
(context as PolytechnicApplication) appContainer.networkCacheRepository.put(url, it)
.container.networkCacheRepository.put(url, it)
} }
listener.onResponse(it) listener.onResponse(it)
}, errorListener) { },
private val appContainer: AppContainer = (context as PolytechnicApplication).container errorListener
) {
private suspend fun getXlsUrl(): MyResult<String> = withContext(Dispatchers.IO) { private suspend fun getXlsUrl(): MyResult<String> = withContext(Dispatchers.IO) {
val mainPageFuture = RequestFuture.newFuture<String>() val mainPageFuture = RequestFuture.newFuture<String>()
@@ -41,7 +46,7 @@ open class CachedRequest(
mainPageFuture, mainPageFuture,
mainPageFuture mainPageFuture
) )
NetworkConnection.getInstance(context).addToRequestQueue(request) NetworkConnection.getInstance(appContext).addToRequestQueue(request)
val response = tryGet(mainPageFuture) val response = tryGet(mainPageFuture)
if (response is MyResult.Failure) if (response is MyResult.Failure)
@@ -49,7 +54,8 @@ open class CachedRequest(
val pageData = (response as MyResult.Success).data val pageData = (response as MyResult.Success).data
val remoteConfig = (context.applicationContext as PolytechnicApplication).container.remoteConfig val remoteConfig =
(appContext.applicationContext as Application).container.remoteConfig
val pattern: Pattern = val pattern: Pattern =
Pattern.compile(remoteConfig.getString("linkParserRegex"), Pattern.MULTILINE) Pattern.compile(remoteConfig.getString("linkParserRegex"), Pattern.MULTILINE)
@@ -67,10 +73,10 @@ open class CachedRequest(
when (val xlsUrl = getXlsUrl()) { when (val xlsUrl = getXlsUrl()) {
is MyResult.Failure -> xlsUrl is MyResult.Failure -> xlsUrl
is MyResult.Success -> { is MyResult.Success -> {
tryFuture { tryFuture(appContext) { it ->
ScheduleUpdate( ScheduleUpdate(
appContext.appContainer,
ScheduleUpdate.RequestDto(xlsUrl.data), ScheduleUpdate.RequestDto(xlsUrl.data),
context,
it, it,
it it
) )
@@ -80,23 +86,24 @@ open class CachedRequest(
} }
} }
override fun send() { override fun send(context: Context) {
// TODO: network cache
val logger = Logger.getLogger("CachedRequest") val logger = Logger.getLogger("CachedRequest")
val repository = appContainer.networkCacheRepository val cache = appContainer.networkCacheRepository
val cacheStatusResult = tryFuture { val cacheStatusResult = tryFuture(context) {
ScheduleGetCacheStatus(context, it, it) ScheduleGetCacheStatus(appContainer, it, it)
} }
if (cacheStatusResult is MyResult.Success) { if (cacheStatusResult is MyResult.Success) {
val cacheStatus = cacheStatusResult.data val cacheStatus = cacheStatusResult.data
runBlocking { runBlocking {
repository.setUpdateDates( cache.setUpdateDates(
cacheStatus.lastCacheUpdate, cacheStatus.lastCacheUpdate,
cacheStatus.lastScheduleUpdate cacheStatus.lastScheduleUpdate
) )
repository.setHash(cacheStatus.cacheHash) cache.setHash(cacheStatus.cacheHash)
} }
if (cacheStatus.cacheUpdateRequired) { if (cacheStatus.cacheUpdateRequired) {
@@ -105,11 +112,11 @@ open class CachedRequest(
when (updateResult) { when (updateResult) {
is MyResult.Success -> { is MyResult.Success -> {
runBlocking { runBlocking {
repository.setUpdateDates( cache.setUpdateDates(
updateResult.data.lastCacheUpdate, updateResult.data.lastCacheUpdate,
updateResult.data.lastScheduleUpdate updateResult.data.lastScheduleUpdate
) )
repository.setHash(updateResult.data.cacheHash) cache.setHash(updateResult.data.cacheHash)
} }
} }
@@ -122,12 +129,12 @@ open class CachedRequest(
logger.warning("Failed to get cache status!") logger.warning("Failed to get cache status!")
} }
val cachedResponse = runBlocking { repository.get(url) } val cachedResponse = runBlocking { cache.get(url) }
if (cachedResponse != null) { if (cachedResponse != null) {
listener.onResponse(cachedResponse.data) listener.onResponse(cachedResponse.data)
return return
} }
super.send() super.send(context)
} }
} }

View File

@@ -1,19 +1,19 @@
package ru.n08i40k.polytechnic.next.network.request.auth package ru.n08i40k.polytechnic.next.network.request.auth
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
class AuthChangePassword( class AuthChangePassword(
appContainer: AppContainer,
private val data: RequestDto, private val data: RequestDto,
context: Context,
listener: Response.Listener<Nothing>, listener: Response.Listener<Nothing>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : AuthorizedRequest( ) : AuthorizedRequest(
context, appContainer,
Method.POST, Method.POST,
"v1/auth/change-password", "v1/auth/change-password",
{ listener.onResponse(null) }, { listener.onResponse(null) },

View File

@@ -1,25 +1,45 @@
package ru.n08i40k.polytechnic.next.network.request.auth package ru.n08i40k.polytechnic.next.network.request.auth
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.VolleyError
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.model.Profile import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.network.RequestBase import ru.n08i40k.polytechnic.next.network.RequestBase
import ru.n08i40k.polytechnic.next.utils.EnumAsStringSerializer
class AuthSignIn( class AuthSignIn(
private val data: RequestDto, private val data: RequestDto,
context: Context,
listener: Response.Listener<Profile>, listener: Response.Listener<Profile>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : RequestBase( ) : RequestBase(
context,
Method.POST, Method.POST,
"v2/auth/sign-in", "v1/auth/sign-in",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {
companion object {
private class ErrorCodeSerializer : EnumAsStringSerializer<ErrorCode>(
"SignInErrorCode",
{ it.value },
{ v -> ErrorCode.entries.first { it.value == v } }
)
@Serializable(with = ErrorCodeSerializer::class)
enum class ErrorCode(val value: String) {
INCORRECT_CREDENTIALS("INCORRECT_CREDENTIALS"),
INVALID_VK_ACCESS_TOKEN("INVALID_VK_ACCESS_TOKEN"),
}
@Serializable
data class Error(val code: ErrorCode)
fun parseError(error: VolleyError): Error {
return Json.decodeFromString<Error>(error.networkResponse.data.decodeToString())
}
}
@Serializable @Serializable
data class RequestDto(val username: String, val password: String) data class RequestDto(val username: String, val password: String)

View File

@@ -0,0 +1,26 @@
package ru.n08i40k.polytechnic.next.network.request.auth
import com.android.volley.Response
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.network.RequestBase
class AuthSignInVK(
private val data: RequestDto,
listener: Response.Listener<Profile>,
errorListener: Response.ErrorListener?
) : RequestBase(
Method.POST,
"v1/auth/sign-in-vk",
{ listener.onResponse(Json.decodeFromString(it)) },
errorListener
) {
@Serializable
data class RequestDto(val accessToken: String)
override fun getBody(): ByteArray {
return Json.encodeToString(data).toByteArray()
}
}

View File

@@ -1,32 +1,56 @@
package ru.n08i40k.polytechnic.next.network.request.auth package ru.n08i40k.polytechnic.next.network.request.auth
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.VolleyError
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.model.Profile import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.model.UserRole import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.network.RequestBase import ru.n08i40k.polytechnic.next.network.RequestBase
import ru.n08i40k.polytechnic.next.utils.EnumAsStringSerializer
class AuthSignUp( class AuthSignUp(
private val data: RequestDto, private val data: RequestDto,
context: Context,
listener: Response.Listener<Profile>, listener: Response.Listener<Profile>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : RequestBase( ) : RequestBase(
context,
Method.POST, Method.POST,
"v2/auth/sign-up", "v1/auth/sign-up",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {
companion object {
private class ErrorCodeSerializer : EnumAsStringSerializer<ErrorCode>(
"SignInErrorCode",
{ it.value },
{ v -> ErrorCode.entries.first { it.value == v } }
)
@Serializable(with = ErrorCodeSerializer::class)
enum class ErrorCode(val value: String) {
USERNAME_ALREADY_EXISTS("USERNAME_ALREADY_EXISTS"),
VK_ALREADY_EXISTS("VK_ALREADY_EXISTS"),
INVALID_VK_ACCESS_TOKEN("INVALID_VK_ACCESS_TOKEN"),
INVALID_GROUP_NAME("INVALID_GROUP_NAME"),
DISALLOWED_ROLE("DISALLOWED_ROLE"),
}
@Serializable
data class Error(val code: ErrorCode)
fun parseError(error: VolleyError): Error {
return Json.decodeFromString<Error>(error.networkResponse.data.decodeToString())
}
}
@Serializable @Serializable
data class RequestDto( data class RequestDto(
val username: String, val username: String,
val password: String, val password: String,
val group: String, val group: String,
val role: UserRole val role: UserRole,
val version: String
) )
override fun getBody(): ByteArray { override fun getBody(): ByteArray {

View File

@@ -0,0 +1,33 @@
package ru.n08i40k.polytechnic.next.network.request.auth
import com.android.volley.Response
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.network.RequestBase
class AuthSignUpVK(
private val data: RequestDto,
listener: Response.Listener<Profile>,
errorListener: Response.ErrorListener?
) : RequestBase(
Method.POST,
"v1/auth/sign-up-vk",
{ listener.onResponse(Json.decodeFromString(it)) },
errorListener
) {
@Serializable
data class RequestDto(
val accessToken: String,
val username: String,
val group: String,
val role: UserRole,
val version: String
)
override fun getBody(): ByteArray {
return Json.encodeToString(data).toByteArray()
}
}

View File

@@ -1,18 +1,26 @@
package ru.n08i40k.polytechnic.next.network.request.fcm package ru.n08i40k.polytechnic.next.network.request.fcm
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
class FcmSetToken( class FcmSetToken(
context: Context, appContainer: AppContainer,
token: String, token: String,
listener: Response.Listener<Unit>, listener: Response.Listener<Unit>,
errorListener: Response.ErrorListener?, errorListener: Response.ErrorListener?,
) : AuthorizedRequest( ) : AuthorizedRequest(
context, Method.POST, appContainer,
"v1/fcm/set-token/$token", Method.PATCH,
"v1/fcm/set-token?token=$token",
{ listener.onResponse(Unit) }, { listener.onResponse(Unit) },
errorListener, errorListener,
true true
) ) {
override fun getHeaders(): MutableMap<String, String> {
val headers = super.getHeaders()
headers.remove("Content-Type")
return headers
}
}

View File

@@ -1,16 +1,19 @@
package ru.n08i40k.polytechnic.next.network.request.fcm package ru.n08i40k.polytechnic.next.network.request.fcm
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
// TODO: вернуть
@Suppress("unused")
class FcmUpdateCallback( class FcmUpdateCallback(
context: Context, appContainer: AppContainer,
version: String, version: String,
listener: Response.Listener<Unit>, listener: Response.Listener<Unit>,
errorListener: Response.ErrorListener?, errorListener: Response.ErrorListener?,
) : AuthorizedRequest( ) : AuthorizedRequest(
context, Method.POST, appContainer,
Method.POST,
"v1/fcm/update-callback/$version", "v1/fcm/update-callback/$version",
{ listener.onResponse(Unit) }, { listener.onResponse(Unit) },
errorListener, errorListener,

View File

@@ -1,19 +1,19 @@
package ru.n08i40k.polytechnic.next.network.request.profile package ru.n08i40k.polytechnic.next.network.request.profile
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
class ProfileChangeGroup( class ProfileChangeGroup(
appContainer: AppContainer,
private val data: RequestDto, private val data: RequestDto,
context: Context,
listener: Response.Listener<Nothing>, listener: Response.Listener<Nothing>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : AuthorizedRequest( ) : AuthorizedRequest(
context, appContainer,
Method.POST, Method.POST,
"v1/users/change-group", "v1/users/change-group",
{ listener.onResponse(null) }, { listener.onResponse(null) },

View File

@@ -1,19 +1,19 @@
package ru.n08i40k.polytechnic.next.network.request.profile package ru.n08i40k.polytechnic.next.network.request.profile
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
class ProfileChangeUsername( class ProfileChangeUsername(
appContainer: AppContainer,
private val data: RequestDto, private val data: RequestDto,
context: Context,
listener: Response.Listener<Nothing>, listener: Response.Listener<Nothing>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : AuthorizedRequest( ) : AuthorizedRequest(
context, appContainer,
Method.POST, Method.POST,
"v1/users/change-username", "v1/users/change-username",
{ listener.onResponse(null) }, { listener.onResponse(null) },

View File

@@ -1,19 +1,19 @@
package ru.n08i40k.polytechnic.next.network.request.profile package ru.n08i40k.polytechnic.next.network.request.profile
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.model.Profile import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
class ProfileMe( class ProfileMe(
context: Context, appContainer: AppContainer,
listener: Response.Listener<Profile>, listener: Response.Listener<Profile>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : AuthorizedRequest( ) : AuthorizedRequest(
context, appContainer,
Method.GET, Method.GET,
"v2/users/me", "v1/users/me",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) )

View File

@@ -1,20 +1,20 @@
package ru.n08i40k.polytechnic.next.network.request.schedule package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.network.request.CachedRequest import ru.n08i40k.polytechnic.next.network.request.CachedRequest
class ScheduleGet( class ScheduleGet(
context: Context, appContainer: AppContainer,
listener: Response.Listener<ResponseDto>, listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null errorListener: Response.ErrorListener? = null
) : CachedRequest( ) : CachedRequest(
context, appContainer,
Method.GET, Method.GET,
"v4/schedule/group", "v1/schedule/group",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {

View File

@@ -1,19 +1,19 @@
package ru.n08i40k.polytechnic.next.network.request.schedule package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
class ScheduleGetCacheStatus( class ScheduleGetCacheStatus(
context: Context, appContainer: AppContainer,
listener: Response.Listener<ResponseDto>, listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null errorListener: Response.ErrorListener? = null
) : AuthorizedRequest( ) : AuthorizedRequest(
context, appContainer,
Method.GET, Method.GET,
"v2/schedule/cache-status", "v1/schedule/cache-status",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {

View File

@@ -1,19 +1,16 @@
package ru.n08i40k.polytechnic.next.network.request.schedule package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.network.RequestBase import ru.n08i40k.polytechnic.next.network.RequestBase
class ScheduleGetGroupNames( class ScheduleGetGroupNames(
context: Context,
listener: Response.Listener<ResponseDto>, listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null errorListener: Response.ErrorListener? = null
) : RequestBase( ) : RequestBase(
context,
Method.GET, Method.GET,
"v2/schedule/group-names", "v1/schedule/group-names",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {

View File

@@ -1,21 +1,21 @@
package ru.n08i40k.polytechnic.next.network.request.schedule package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.network.request.CachedRequest import ru.n08i40k.polytechnic.next.network.request.CachedRequest
class ScheduleGetTeacher( class ScheduleGetTeacher(
context: Context, appContainer: AppContainer,
teacher: String, teacher: String,
listener: Response.Listener<ResponseDto>, listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null errorListener: Response.ErrorListener? = null
) : CachedRequest( ) : CachedRequest(
context, appContainer,
Method.GET, Method.GET,
"v3/schedule/teacher/$teacher", "v1/schedule/teacher/$teacher",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {

View File

@@ -1,19 +1,16 @@
package ru.n08i40k.polytechnic.next.network.request.schedule package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.network.RequestBase import ru.n08i40k.polytechnic.next.network.RequestBase
class ScheduleGetTeacherNames( class ScheduleGetTeacherNames(
context: Context,
listener: Response.Listener<ResponseDto>, listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null errorListener: Response.ErrorListener? = null
) : RequestBase( ) : RequestBase(
context,
Method.GET, Method.GET,
"v2/schedule/teacher-names", "v1/schedule/teacher-names",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {

View File

@@ -1,21 +1,21 @@
package ru.n08i40k.polytechnic.next.network.request.schedule package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
class ScheduleUpdate( class ScheduleUpdate(
appContainer: AppContainer,
private val data: RequestDto, private val data: RequestDto,
context: Context,
listener: Response.Listener<ScheduleGetCacheStatus.ResponseDto>, listener: Response.Listener<ScheduleGetCacheStatus.ResponseDto>,
errorListener: Response.ErrorListener? = null errorListener: Response.ErrorListener? = null
) : AuthorizedRequest( ) : AuthorizedRequest(
context, appContainer,
Method.PATCH, Method.PATCH,
"v4/schedule/update-download-url", "v1/schedule/update-download-url",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {

View File

@@ -1,17 +1,19 @@
package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
// TODO: вернуть
@Suppress("unused")
class ScheduleReplacerClear( class ScheduleReplacerClear(
context: Context, appContainer: AppContainer,
listener: Response.Listener<ResponseDto>, listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : AuthorizedRequest( ) : AuthorizedRequest(
context, appContainer,
Method.POST, Method.POST,
"v1/schedule-replacer/clear", "v1/schedule-replacer/clear",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },

View File

@@ -1,17 +1,19 @@
package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
// TODO: вернуть
@Suppress("unused")
class ScheduleReplacerGet( class ScheduleReplacerGet(
context: Context, appContainer: AppContainer,
listener: Response.Listener<List<ScheduleReplacer>>, listener: Response.Listener<List<ScheduleReplacer>>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : AuthorizedRequest( ) : AuthorizedRequest(
context, appContainer,
Method.GET, Method.GET,
"v1/schedule-replacer/get", "v1/schedule-replacer/get",
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },

View File

@@ -1,18 +1,20 @@
package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer
import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import ru.n08i40k.polytechnic.next.network.AuthorizedMultipartRequest import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.network.request.AuthorizedMultipartRequest
// TODO: вернуть
@Suppress("unused")
class ScheduleReplacerSet( class ScheduleReplacerSet(
context: Context, appContainer: AppContainer,
private val fileName: String, private val fileName: String,
private val fileData: ByteArray, private val fileData: ByteArray,
private val fileType: String, private val fileType: String,
private val listener: Response.Listener<Nothing>, private val listener: Response.Listener<Nothing>,
errorListener: Response.ErrorListener? errorListener: Response.ErrorListener?
) : AuthorizedMultipartRequest( ) : AuthorizedMultipartRequest(
context, appContainer,
Method.POST, Method.POST,
"v1/schedule-replacer/set", "v1/schedule-replacer/set",
{ listener.onResponse(null) }, { listener.onResponse(null) },

View File

@@ -0,0 +1,35 @@
package ru.n08i40k.polytechnic.next.network.request.vkid
import com.android.volley.Request.Method
import com.android.volley.Response
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.network.RequestBase
class VKIDOAuth(
private val data: RequestDto,
listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener?,
) : RequestBase(
Method.POST,
"v1/vkid/oauth",
{ listener.onResponse(Json.decodeFromString(it)) },
errorListener
) {
@Serializable
data class RequestDto(
val code: String,
val codeVerifier: String,
val deviceId: String,
)
@Serializable
data class ResponseDto(
val accessToken: String,
)
override fun getBody(): ByteArray {
return Json.encodeToString(data).toByteArray()
}
}

View File

@@ -1,4 +1,4 @@
package ru.n08i40k.polytechnic.next.data.cache package ru.n08i40k.polytechnic.next.repository.cache
import ru.n08i40k.polytechnic.next.CachedResponse import ru.n08i40k.polytechnic.next.CachedResponse
import ru.n08i40k.polytechnic.next.UpdateDates import ru.n08i40k.polytechnic.next.UpdateDates

View File

@@ -1,6 +1,5 @@
package ru.n08i40k.polytechnic.next.data.cache.impl package ru.n08i40k.polytechnic.next.repository.cache.impl
import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -8,23 +7,26 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.CachedResponse import ru.n08i40k.polytechnic.next.CachedResponse
import ru.n08i40k.polytechnic.next.UpdateDates import ru.n08i40k.polytechnic.next.UpdateDates
import ru.n08i40k.polytechnic.next.data.cache.NetworkCacheRepository import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.settings.settingsDataStore import ru.n08i40k.polytechnic.next.repository.cache.NetworkCacheRepository
import ru.n08i40k.polytechnic.next.settings.settings
import javax.inject.Inject import javax.inject.Inject
class LocalNetworkCacheRepository class LocalNetworkCacheRepository
@Inject constructor(private val applicationContext: Context) : NetworkCacheRepository { @Inject constructor(private val appContainer: AppContainer) : NetworkCacheRepository {
private val cacheMap: MutableMap<String, CachedResponse> = mutableMapOf() private val cacheMap: MutableMap<String, CachedResponse> = mutableMapOf()
private var updateDates: UpdateDates = UpdateDates.newBuilder().build() private var updateDates: UpdateDates = UpdateDates.newBuilder().build()
private var hash: String? = null private var hash: String? = null
private val context get() = appContainer.context
init { init {
cacheMap.clear() cacheMap.clear()
runBlocking { runBlocking {
cacheMap.putAll( cacheMap.putAll(
applicationContext context
.settingsDataStore .settings
.data .data
.map { settings -> settings.cacheStorageMap }.first() .map { settings -> settings.cacheStorageMap }.first()
) )
@@ -32,7 +34,7 @@ class LocalNetworkCacheRepository
} }
override suspend fun get(url: String): CachedResponse? { override suspend fun get(url: String): CachedResponse? {
// Если кешированого ответа нет, то возвращаем null // Если кешированного ответа нет, то возвращаем null
// Если хеши не совпадают и локальный хеш присутствует, то возвращаем null // Если хеши не совпадают и локальный хеш присутствует, то возвращаем null
val response = cacheMap[url] ?: return null val response = cacheMap[url] ?: return null
@@ -92,7 +94,7 @@ class LocalNetworkCacheRepository
.setSchedule(schedule).build() .setSchedule(schedule).build()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
applicationContext.settingsDataStore.updateData { context.settings.updateData {
it it
.toBuilder() .toBuilder()
.setUpdateDates(updateDates) .setUpdateDates(updateDates)
@@ -104,7 +106,7 @@ class LocalNetworkCacheRepository
private suspend fun save() { private suspend fun save() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
applicationContext.settingsDataStore.updateData { context.settings.updateData {
it it
.toBuilder() .toBuilder()
.putAllCacheStorage(cacheMap) .putAllCacheStorage(cacheMap)

View File

@@ -1,10 +1,10 @@
package ru.n08i40k.polytechnic.next.data.cache.impl package ru.n08i40k.polytechnic.next.repository.cache.impl
import ru.n08i40k.polytechnic.next.CachedResponse import ru.n08i40k.polytechnic.next.CachedResponse
import ru.n08i40k.polytechnic.next.UpdateDates import ru.n08i40k.polytechnic.next.UpdateDates
import ru.n08i40k.polytechnic.next.data.cache.NetworkCacheRepository import ru.n08i40k.polytechnic.next.repository.cache.NetworkCacheRepository
class FakeNetworkCacheRepository : NetworkCacheRepository { class MockNetworkCacheRepository : NetworkCacheRepository {
override suspend fun get(url: String): CachedResponse? { override suspend fun get(url: String): CachedResponse? {
return null return null
} }

View File

@@ -0,0 +1,12 @@
package ru.n08i40k.polytechnic.next.repository.profile
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.utils.MyResult
interface ProfileRepository {
suspend fun getProfile(): MyResult<Profile>
suspend fun setFCMToken(token: String): MyResult<Unit>
suspend fun signOut()
}

View File

@@ -0,0 +1,39 @@
package ru.n08i40k.polytechnic.next.repository.profile.impl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.repository.profile.ProfileRepository
import ru.n08i40k.polytechnic.next.utils.MyResult
class MockProfileRepository : ProfileRepository {
private var getCounter = 0
companion object {
val profile = Profile(
"66db32d24030a07e02d974c5",
"n08i40k",
"ИС-214/23",
UserRole.STUDENT
)
}
override suspend fun getProfile(): MyResult<Profile> =
withContext(Dispatchers.IO) {
delay(1500)
if (++getCounter % 3 == 0)
MyResult.Failure(Exception())
else
MyResult.Success(profile)
}
override suspend fun setFCMToken(token: String): MyResult<Unit> =
MyResult.Success(Unit)
override suspend fun signOut() {
}
}

View File

@@ -0,0 +1,59 @@
package ru.n08i40k.polytechnic.next.repository.profile.impl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.network.request.fcm.FcmSetToken
import ru.n08i40k.polytechnic.next.network.request.profile.ProfileMe
import ru.n08i40k.polytechnic.next.network.tryFuture
import ru.n08i40k.polytechnic.next.repository.profile.ProfileRepository
import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.utils.MyResult
import ru.n08i40k.polytechnic.next.utils.app
class RemoteProfileRepository(private val container: AppContainer) : ProfileRepository {
override suspend fun getProfile(): MyResult<Profile> {
return withContext(Dispatchers.IO) {
tryFuture(container.context) {
ProfileMe(
container,
it,
it
)
}
}
}
override suspend fun setFCMToken(token: String): MyResult<Unit> =
withContext(Dispatchers.IO) {
tryFuture(container.context) {
FcmSetToken(
container,
token,
it,
it
)
}
}
override suspend fun signOut() {
val context = container.context
container.context.settings.updateData {
it
.toBuilder()
.clear()
.build()
}
context.app.events.signOut.next(Unit)
// context.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)
// val pm = context.packageManager
// val intent = pm.getLaunchIntentForPackage(context.packageName)
// val mainIntent = Intent.makeRestartActivityTask(intent?.component)
// context.startActivity(mainIntent)
// Runtime.getRuntime().exit(0)
}
}

View File

@@ -1,7 +1,7 @@
package ru.n08i40k.polytechnic.next.data.schedule package ru.n08i40k.polytechnic.next.repository.schedule
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.utils.MyResult
interface ScheduleRepository { interface ScheduleRepository {
suspend fun getGroup(): MyResult<GroupOrTeacher> suspend fun getGroup(): MyResult<GroupOrTeacher>

View File

@@ -1,21 +1,20 @@
package ru.n08i40k.polytechnic.next.data.schedule.impl package ru.n08i40k.polytechnic.next.repository.schedule.impl
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.model.Day import ru.n08i40k.polytechnic.next.model.Day
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.model.Lesson import ru.n08i40k.polytechnic.next.model.Lesson
import ru.n08i40k.polytechnic.next.model.LessonTime import ru.n08i40k.polytechnic.next.model.LessonTime
import ru.n08i40k.polytechnic.next.model.LessonType import ru.n08i40k.polytechnic.next.model.LessonType
import ru.n08i40k.polytechnic.next.model.SubGroup import ru.n08i40k.polytechnic.next.model.SubGroup
import ru.n08i40k.polytechnic.next.repository.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.utils.MyResult
import ru.n08i40k.polytechnic.next.utils.now import ru.n08i40k.polytechnic.next.utils.now
private fun genLocalDateTime(hour: Int, minute: Int): Instant { private fun genLocalDateTime(hour: Int, minute: Int): Instant {
@@ -36,8 +35,7 @@ private fun genBreak(start: Instant, end: Instant): Lesson {
) )
} }
class FakeScheduleRepository : ScheduleRepository { class MockScheduleRepository : ScheduleRepository {
@Suppress("SpellCheckingInspection")
companion object { companion object {
val exampleGroup = GroupOrTeacher( val exampleGroup = GroupOrTeacher(
name = "ИС-214/23", days = arrayListOf( name = "ИС-214/23", days = arrayListOf(
@@ -144,7 +142,8 @@ class FakeScheduleRepository : ScheduleRepository {
), ),
group = null group = null
), ),
) ),
street = "Железнодорожная 13",
) )
) )
) )
@@ -254,34 +253,28 @@ class FakeScheduleRepository : ScheduleRepository {
), ),
group = "ИС-214/23" group = "ИС-214/23"
), ),
) ),
null
) )
) )
) )
} }
private val group = MutableStateFlow<GroupOrTeacher?>(exampleGroup)
private val teacher = MutableStateFlow<GroupOrTeacher?>(exampleTeacher)
private var updateCounter: Int = 0 private var updateCounter: Int = 0
override suspend fun getGroup(): MyResult<GroupOrTeacher> { override suspend fun getGroup(): MyResult<GroupOrTeacher> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
delay(1500) delay(1500)
if (updateCounter++ % 3 == 0) MyResult.Failure( if (updateCounter++ % 3 == 0) MyResult.Failure()
IllegalStateException() else MyResult.Success(exampleGroup)
)
else MyResult.Success(group.value!!)
} }
} }
override suspend fun getTeacher(name: String): MyResult<GroupOrTeacher> { override suspend fun getTeacher(name: String): MyResult<GroupOrTeacher> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
delay(1500) delay(1500)
if (updateCounter++ % 3 == 0) MyResult.Failure( if (updateCounter++ % 3 == 0) MyResult.Failure()
IllegalStateException() else MyResult.Success(exampleTeacher)
)
else MyResult.Success(teacher.value!!)
} }
} }
} }

View File

@@ -1,37 +1,39 @@
package ru.n08i40k.polytechnic.next.data.schedule.impl package ru.n08i40k.polytechnic.next.repository.schedule.impl
import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGet import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGet
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetTeacher import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetTeacher
import ru.n08i40k.polytechnic.next.network.tryFuture import ru.n08i40k.polytechnic.next.network.tryFuture
import ru.n08i40k.polytechnic.next.repository.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.utils.MyResult
class RemoteScheduleRepository(private val container: AppContainer) : ScheduleRepository {
private val context get() = container.context
class RemoteScheduleRepository(private val context: Context) : ScheduleRepository {
override suspend fun getGroup(): MyResult<GroupOrTeacher> = override suspend fun getGroup(): MyResult<GroupOrTeacher> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val response = tryFuture { val response = tryFuture(context) {
ScheduleGet( ScheduleGet(
context, container,
it, it,
it it
) )
} }
when (response) { when (response) {
is MyResult.Failure -> response
is MyResult.Success -> MyResult.Success(response.data.group) is MyResult.Success -> MyResult.Success(response.data.group)
is MyResult.Failure -> response
} }
} }
override suspend fun getTeacher(name: String): MyResult<GroupOrTeacher> = override suspend fun getTeacher(name: String): MyResult<GroupOrTeacher> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val response = tryFuture { val response = tryFuture(context) {
ScheduleGetTeacher( ScheduleGetTeacher(
context, container,
name, name,
it, it,
it it

View File

@@ -1,210 +0,0 @@
package ru.n08i40k.polytechnic.next.service
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat.startForegroundService
import kotlinx.datetime.LocalDateTime
import ru.n08i40k.polytechnic.next.NotificationChannels
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.Day
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.utils.dayMinutes
import ru.n08i40k.polytechnic.next.utils.fmtAsClock
import ru.n08i40k.polytechnic.next.utils.getDayMinutes
import ru.n08i40k.polytechnic.next.utils.now
import java.util.Calendar
import java.util.logging.Logger
class CurrentLessonViewService : Service() {
companion object {
private const val NOTIFICATION_STATUS_ID = 1337
private const val NOTIFICATION_END_ID = NOTIFICATION_STATUS_ID + 1
private const val UPDATE_INTERVAL = 1_000L
suspend fun startService(application: PolytechnicApplication) {
if (!application.hasNotificationPermission())
return
val schedule =
application
.container
.scheduleRepository
.getGroup()
if (schedule is MyResult.Failure)
return
val intent = Intent(application, CurrentLessonViewService::class.java)
.apply {
putExtra("group", (schedule as MyResult.Success).data)
}
application.stopService(
Intent(
application,
CurrentLessonViewService::class.java
)
)
startForegroundService(application, intent)
}
}
private lateinit var day: Day
private val handler = Handler(Looper.getMainLooper())
private val updateRunnable = object : Runnable {
override fun run() {
val (currentIndex, currentLesson) = day.currentKV ?: (null to null)
val (nextIndex, _) = day.distanceToNext(currentIndex)
?: (null to null)
val nextLesson = nextIndex?.let { day.lessons[nextIndex] }
if (currentLesson == null && nextLesson == null) {
onLessonsEnd()
return
}
handler.postDelayed(this, UPDATE_INTERVAL)
val context = this@CurrentLessonViewService
val currentMinutes = LocalDateTime.now().dayMinutes
val distanceToFirst = day.first!!.time.start.dayMinutes - currentMinutes
val currentLessonName =
currentLesson?.getNameAndCabinetsShort(context)
?: run {
if (distanceToFirst > 0)
getString(R.string.lessons_not_started)
else
getString(R.string.lesson_break)
}
val nextLessonName =
nextLesson?.getNameAndCabinetsShort(context) ?: getString(R.string.lessons_end)
val nextLessonIn =
(currentLesson?.time?.end ?: nextLesson!!.time.start).dayMinutes
val notification = createNotification(
getString(
if (distanceToFirst > 0) R.string.waiting_for_day_start_notification_title
else R.string.lesson_going_notification_title,
(nextLessonIn - currentMinutes) / 60,
(nextLessonIn - currentMinutes) % 60
),
getString(
R.string.lesson_going_notification_description,
currentLessonName,
nextLessonIn.fmtAsClock(),
nextLessonName,
)
)
getNotificationManager().notify(NOTIFICATION_STATUS_ID, notification)
}
}
private fun onLessonsEnd() {
val notification = NotificationCompat
.Builder(applicationContext, NotificationChannels.LESSON_VIEW)
.setSmallIcon(R.drawable.schedule)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentTitle(getString(R.string.lessons_end_notification_title))
.setContentText(getString(R.string.lessons_end_notification_description))
.build()
getNotificationManager().notify(NOTIFICATION_END_ID, notification)
stopSelf()
}
private fun createNotification(
title: String? = null,
description: String? = null
): Notification {
return NotificationCompat
.Builder(applicationContext, NotificationChannels.LESSON_VIEW)
.setSmallIcon(R.drawable.schedule)
.setContentTitle(title ?: getString(R.string.lesson_notification_title))
.setContentText(description ?: getString(R.string.lesson_notification_description))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.setSilent(true)
.build()
}
private fun getNotificationManager(): NotificationManager {
return getSystemService(NOTIFICATION_SERVICE) as NotificationManager
}
private fun updateSchedule(group: GroupOrTeacher?) {
val logger = Logger.getLogger("CLV")
if (group == null) {
logger.warning("Stopping, because group is null")
stopSelf()
return
}
val currentDay = group.current
if (currentDay == null || currentDay.lessons.isEmpty()) {
logger.warning("Stopping, because current day is null or empty")
stopSelf()
return
}
val nowMinutes = Calendar.getInstance().getDayMinutes()
if (nowMinutes < ((5 * 60) + 30)
|| currentDay.last!!.time.end.dayMinutes < nowMinutes
) {
logger.warning("Stopping, because service started outside of acceptable time range!")
stopSelf()
return
}
this.day = currentDay
this.handler.removeCallbacks(updateRunnable)
updateRunnable.run()
logger.info("Running...")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!(applicationContext as PolytechnicApplication).hasNotificationPermission()) {
stopSelf()
return START_STICKY
}
if (intent == null)
throw NullPointerException("Intent shouldn't be null!")
val notification = createNotification()
startForeground(NOTIFICATION_STATUS_ID, notification)
updateSchedule(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("group", GroupOrTeacher::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("group")
}
)
return START_STICKY
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
}

View File

@@ -0,0 +1,159 @@
package ru.n08i40k.polytechnic.next.service
import android.Manifest
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.app.NotificationChannels
import ru.n08i40k.polytechnic.next.worker.UpdateFCMTokenWorker
private data class ScheduleUpdateData(
val type: String,
val replaced: Boolean,
val etag: String
) {
constructor(message: RemoteMessage) : this(
type = message.data["type"]
?: throw IllegalArgumentException("Type is missing in RemoteMessage"),
replaced = message.data["replaced"]?.toBoolean()
?: throw IllegalArgumentException("Replaced is missing in RemoteMessage"),
etag = message.data["etag"]
?: throw IllegalArgumentException("Etag is missing in RemoteMessage")
)
fun handleMessage(service: FCMService) {
service.sendNotification(
NotificationChannels.SCHEDULE_UPDATE,
R.drawable.schedule,
service.getString(R.string.schedule_update_title),
service.getString(
if (replaced)
R.string.schedule_update_replaced
else
R.string.schedule_update_default
),
etag
)
}
}
private data class LessonsStartData(
val type: String
) {
constructor(message: RemoteMessage) : this(
type = message.data["type"]
?: throw IllegalArgumentException("Type is missing in RemoteMessage")
)
// TODO: вернуть
@Suppress("unused")
fun handleMessage(service: FCMService) {
// Uncomment and implement if needed
// service.scope.launch {
// CurrentLessonViewService
// .startService(service.applicationContext as PolytechnicApplication)
// }
}
}
private data class AppUpdateData(
val type: String,
val version: String,
val downloadLink: String
) {
constructor(message: RemoteMessage) : this(
type = message.data["type"]
?: throw IllegalArgumentException("Type is missing in RemoteMessage"),
version = message.data["version"]
?: throw IllegalArgumentException("Version is missing in RemoteMessage"),
downloadLink = message.data["downloadLink"]
?: throw IllegalArgumentException("DownloadLink is missing in RemoteMessage")
)
fun handleMessage(service: FCMService) {
service.sendNotification(
NotificationChannels.APP_UPDATE,
R.drawable.download,
service.getString(R.string.app_update_title, version),
service.getString(R.string.app_update_description),
version,
Intent(Intent.ACTION_VIEW, Uri.parse(downloadLink))
)
}
}
class FCMService : FirebaseMessagingService() {
// TODO: вернуть
@Suppress("unused")
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onNewToken(token: String) {
super.onNewToken(token)
UpdateFCMTokenWorker.schedule(this, token)
}
fun sendNotification(
channel: String,
@DrawableRes iconId: Int,
title: String,
contentText: String,
id: Any?,
intent: Intent? = null
) {
val pendingIntent: PendingIntent? =
if (intent != null)
PendingIntent.getActivity(this, 0, intent.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}, PendingIntent.FLAG_IMMUTABLE)
else
null
val notification = NotificationCompat
.Builder(applicationContext, channel)
.setSmallIcon(iconId)
.setContentTitle(title)
.setContentText(contentText)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
with(NotificationManagerCompat.from(this)) {
if (ActivityCompat.checkSelfPermission(
this@FCMService,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return@with
}
notify(id.hashCode(), notification)
}
}
override fun onMessageReceived(message: RemoteMessage) {
val type = message.data["type"]
when (type) {
"schedule-update" -> ScheduleUpdateData(message).handleMessage(this)
"lessons-start" -> LessonsStartData(message).handleMessage(this)
"app-update" -> AppUpdateData(message).handleMessage(this)
}
super.onMessageReceived(message)
}
}

View File

@@ -1,130 +0,0 @@
package ru.n08i40k.polytechnic.next.service
import android.Manifest
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.NotificationChannels
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.work.FcmSetTokenWorker
import java.time.Duration
class MyFirebaseMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onNewToken(token: String) {
super.onNewToken(token)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<FcmSetTokenWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.LINEAR, Duration.ofMinutes(1))
.setInputData(workDataOf("TOKEN" to token))
.build()
WorkManager
.getInstance(applicationContext)
.enqueue(request)
}
private fun sendNotification(
channel: String,
@DrawableRes iconId: Int,
title: String,
contentText: String,
id: Any?,
intent: Intent? = null
) {
val pendingIntent: PendingIntent? =
if (intent != null)
PendingIntent.getActivity(this, 0, intent.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}, PendingIntent.FLAG_IMMUTABLE)
else
null
val notification = NotificationCompat
.Builder(applicationContext, channel)
.setSmallIcon(iconId)
.setContentTitle(title)
.setContentText(contentText)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
with(NotificationManagerCompat.from(this)) {
if (ActivityCompat.checkSelfPermission(
this@MyFirebaseMessagingService,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return@with
}
notify(id.hashCode(), notification)
}
}
override fun onMessageReceived(message: RemoteMessage) {
val type = message.data["type"]
when (type) {
"schedule-update" -> {
sendNotification(
NotificationChannels.SCHEDULE_UPDATE,
R.drawable.schedule,
getString(R.string.schedule_update_title),
getString(
if (message.data["replaced"] == "true")
R.string.schedule_update_replaced
else
R.string.schedule_update_default
),
message.data["etag"]
)
}
"lessons-start" -> {
scope.launch {
CurrentLessonViewService
.startService(applicationContext as PolytechnicApplication)
}
}
"app-update" -> {
sendNotification(
NotificationChannels.APP_UPDATE,
R.drawable.download,
getString(R.string.app_update_title, message.data["version"]),
getString(R.string.app_update_description),
message.data["version"],
Intent(Intent.ACTION_VIEW, Uri.parse(message.data["downloadLink"]))
)
}
}
super.onMessageReceived(message)
}
}

View File

@@ -13,18 +13,17 @@ import java.io.OutputStream
object SettingsSerializer : Serializer<Settings> { object SettingsSerializer : Serializer<Settings> {
override val defaultValue: Settings = Settings.getDefaultInstance() override val defaultValue: Settings = Settings.getDefaultInstance()
override suspend fun readFrom(input: InputStream): Settings { override suspend fun readFrom(input: InputStream): Settings =
try { try {
return Settings.parseFrom(input) Settings.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) { } catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception) throw CorruptionException("Cannot read proto.", exception)
} }
}
override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output) override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output)
} }
val Context.settingsDataStore: DataStore<Settings> by dataStore( val Context.settings: DataStore<Settings> by dataStore(
fileName = "settings.pb", fileName = "settings.pb",
serializer = SettingsSerializer serializer = SettingsSerializer
) )

View File

@@ -1,177 +0,0 @@
package ru.n08i40k.polytechnic.next.ui
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.google.firebase.remoteconfig.remoteConfigSettings
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.NotificationChannels
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import ru.n08i40k.polytechnic.next.work.FcmUpdateCallbackWorker
import ru.n08i40k.polytechnic.next.work.LinkUpdateWorker
import java.time.Duration
import java.util.concurrent.TimeUnit
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val configSettings = remoteConfigSettings {
minimumFetchIntervalInSeconds = 3600
}
private fun createNotificationChannel(
notificationManager: NotificationManager,
name: String,
description: String,
channelId: String
) {
val channel = NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_DEFAULT)
channel.description = description
notificationManager.createNotificationChannel(channel)
}
private fun createNotificationChannels() {
if (!(applicationContext as PolytechnicApplication).hasNotificationPermission())
return
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel(
notificationManager,
getString(R.string.schedule_channel_name),
getString(R.string.schedule_channel_description),
NotificationChannels.SCHEDULE_UPDATE
)
createNotificationChannel(
notificationManager,
getString(R.string.app_update_channel_name),
getString(R.string.app_update_channel_description),
NotificationChannels.APP_UPDATE
)
createNotificationChannel(
notificationManager,
getString(R.string.lesson_view_channel_name),
getString(R.string.lesson_view_channel_description),
NotificationChannels.LESSON_VIEW
)
}
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) createNotificationChannels()
}
private fun askNotificationPermission() {
if (!(applicationContext as PolytechnicApplication).hasNotificationPermission())
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
fun scheduleLinkUpdate(intervalInMinutes: Long) {
val tag = "schedule-update"
val workRequest = PeriodicWorkRequest.Builder(
LinkUpdateWorker::class.java,
intervalInMinutes.coerceAtLeast(15), TimeUnit.MINUTES
)
.addTag(tag)
.build()
val workManager = WorkManager.getInstance(applicationContext)
workManager.cancelAllWorkByTag(tag)
workManager.enqueue(workRequest)
}
private fun setupFirebaseConfig() {
val remoteConfig = (application as PolytechnicApplication).container.remoteConfig
remoteConfig.setConfigSettingsAsync(configSettings)
remoteConfig.setDefaultsAsync(R.xml.remote_config_defaults)
remoteConfig
.fetchAndActivate()
.addOnCompleteListener {
if (!it.isSuccessful)
Log.w("RemoteConfig", "Failed to fetch and activate!")
scheduleLinkUpdate(remoteConfig.getLong("linkUpdateDelay"))
}
}
private fun handleUpdate() {
lifecycleScope.launch {
val appVersion = (applicationContext as PolytechnicApplication).getAppVersion()
if (settingsDataStore.data.map { it.version }.first() != appVersion) {
settingsDataStore.updateData { it.toBuilder().setVersion(appVersion).build() }
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<FcmUpdateCallbackWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.LINEAR, Duration.ofMinutes(1))
.setInputData(workDataOf("VERSION" to appVersion))
.build()
WorkManager
.getInstance(this@MainActivity)
.enqueue(request)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
askNotificationPermission()
createNotificationChannels()
setupFirebaseConfig()
handleUpdate()
setContent {
Box(Modifier.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))) {
PolytechnicApp()
}
}
lifecycleScope.launch {
settingsDataStore.data.first()
}
}
}

View File

@@ -1,42 +1,276 @@
package ru.n08i40k.polytechnic.next.ui package ru.n08i40k.polytechnic.next.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.settings.settingsDataStore import ru.n08i40k.polytechnic.next.Application
import ru.n08i40k.polytechnic.next.ui.auth.AuthScreen import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.ui.main.MainScreen import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.ui.theme.AppTheme import ru.n08i40k.polytechnic.next.ui.screen.MainScreen
import ru.n08i40k.polytechnic.next.ui.screen.auth.AuthScreen
import ru.n08i40k.polytechnic.next.utils.app
import ru.n08i40k.polytechnic.next.utils.openLink
import kotlin.system.exitProcess
enum class AppRoute(val route: String) {
AUTH("auth"),
MAIN("main")
}
private data class SemVersion(val major: Int, val minor: Int, val patch: Int) :
Comparable<SemVersion> {
companion object {
fun fromString(version: String): SemVersion {
val numbers = version.split(".").map { it.toInt() }
assert(numbers.size == 3)
return SemVersion(numbers[0], numbers[1], numbers[2])
}
}
override fun equals(other: Any?): Boolean =
when (other) {
is SemVersion -> this.major == other.major && this.minor == other.minor && this.patch == other.patch
else -> false
}
override fun toString(): String {
return "$major.$minor.$patch"
}
override fun compareTo(b: SemVersion): Int {
val majorDiff = this.major - b.major
if (majorDiff != 0) return majorDiff
val minorDiff = this.minor - b.minor
if (minorDiff != 0) return minorDiff
return this.patch - b.patch
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + major
result = 31 * result + minor
result = 31 * result + patch
return result
}
}
@Composable
private fun checkUpdate(): Boolean {
val context = LocalContext.current
val app = context.applicationContext as Application
val remoteConfig = app.container.remoteConfig
val currentVersion = SemVersion.fromString(app.version)
val minRequiredVersion = SemVersion.fromString(remoteConfig.getString("minVersion"))
val downloadLink = remoteConfig.getString("downloadLink")
if (currentVersion < minRequiredVersion) {
Dialog({ exitProcess(0) }, DialogProperties(false, false)) {
Card(border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)) {
var dialogWidth by remember { mutableStateOf(Dp.Unspecified) }
val localDensity = LocalDensity.current
Column(
Modifier
.padding(10.dp)
.onGloballyPositioned {
with(localDensity) {
dialogWidth = it.size.width.toDp()
}
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(R.string.updater_support_end, minRequiredVersion),
Modifier.padding(0.dp, 10.dp),
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(5.dp))
Text(stringResource(R.string.updater_body))
Spacer(Modifier.height(10.dp))
if (dialogWidth != Dp.Unspecified) {
Row(Modifier.width(dialogWidth), Arrangement.SpaceBetween) {
TextButton({ exitProcess(0) }) {
Text(
stringResource(R.string.updater_exit),
color = MaterialTheme.colorScheme.secondaryContainer
)
}
TextButton({ context.openLink(downloadLink) }) {
Text(
stringResource(R.string.updater_update),
color = MaterialTheme.colorScheme.primaryContainer
)
}
}
}
}
}
}
return false
}
val latestVersion = SemVersion.fromString(remoteConfig.getString("currVersion"))
var suppressedVersion by rememberSaveable {
mutableStateOf(
runBlocking {
val data = context.settings.data.map { it.suppressedVersion }.first()
if (data.isEmpty())
"0.0.0"
else
data
}
)
}
val suppressedSemVer by remember { derivedStateOf { SemVersion.fromString(suppressedVersion) } }
if (latestVersion > currentVersion && latestVersion != suppressedSemVer) {
Dialog({ exitProcess(0) }, DialogProperties(false, false)) {
Card(border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)) {
var dialogWidth by remember { mutableStateOf(Dp.Unspecified) }
val localDensity = LocalDensity.current
Column(
Modifier
.padding(10.dp)
.onGloballyPositioned {
with(localDensity) {
dialogWidth = it.size.width.toDp()
}
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(R.string.updater_new_version),
Modifier.padding(0.dp, 10.dp),
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(5.dp))
Text(stringResource(R.string.updater_body))
Spacer(Modifier.height(10.dp))
if (dialogWidth != Dp.Unspecified) {
Row(Modifier.width(dialogWidth), Arrangement.SpaceBetween) {
Row {
TextButton({ suppressedVersion = latestVersion.toString() }) {
Text(
stringResource(R.string.updater_no),
color = MaterialTheme.colorScheme.secondaryContainer
)
}
TextButton({
runBlocking {
context.settings.updateData {
it.toBuilder()
.setSuppressedVersion(latestVersion.toString())
.build()
}
}
suppressedVersion = latestVersion.toString()
}) {
Text(
stringResource(R.string.updater_suppress),
color = MaterialTheme.colorScheme.secondaryContainer
)
}
}
TextButton({ context.openLink(downloadLink) }) {
Text(
stringResource(R.string.updater_update),
color = MaterialTheme.colorScheme.primaryContainer
)
}
}
}
}
}
}
return false
}
return true
}
@Preview(showBackground = true, showSystemUi = true)
@Composable @Composable
fun PolytechnicApp() { fun PolytechnicApp() {
AppTheme(darkTheme = true, content = { if (!checkUpdate())
return
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
val accessToken = runBlocking { remember {
context.settingsDataStore.data.map { it.accessToken }.first() context.app.events.signOut.subscribe(
context,
{
navController.navigate(AppRoute.AUTH.route) {
popUpTo(AppRoute.AUTH.route) { inclusive = true }
}
}
)
}
val token = runBlocking {
context.settings.data.map { it.accessToken }.first()
} }
NavHost( NavHost(
navController = navController, navController,
startDestination = if (accessToken.isEmpty()) "auth" else "main" startDestination = if (token.isEmpty()) AppRoute.AUTH.route else AppRoute.MAIN.route
) { ) {
composable(route = "auth") { composable(AppRoute.AUTH.route) {
AuthScreen(navController) AuthScreen(navController)
} }
composable(route = "main") { composable(AppRoute.MAIN.route) {
MainScreen(navController) MainScreen(navController)
} }
} }
})
} }

View File

@@ -1,149 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.auth
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
@Preview(showBackground = true)
@Composable
fun AuthForm(
appNavController: NavHostController = rememberNavController(),
onPendingSnackbar: (String) -> Unit = {},
) {
val navController = rememberNavController()
val modifier = Modifier.fillMaxSize()
NavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
startDestination = "sign-in",
enterTransition = {
slideIn(
animationSpec = tween(
400,
delayMillis = 250,
easing = LinearOutSlowInEasing
)
) { fullSize -> IntOffset(0, fullSize.height / 16) } + fadeIn(
animationSpec = tween(
400,
delayMillis = 250,
easing = LinearOutSlowInEasing
)
)
},
exitTransition = {
fadeOut(
animationSpec = tween(
250,
easing = FastOutSlowInEasing
)
)
},
) {
composable("sign-in") {
Row(
modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Card(border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)) {
LoginForm(appNavController, navController, onPendingSnackbar)
}
}
}
composable("sign-up") {
Row(
modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Card(border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)) {
RegisterForm(appNavController, navController, onPendingSnackbar)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun AuthScreen(appNavController: NavHostController = rememberNavController()) {
val context = LocalContext.current
LaunchedEffect(Unit) {
val accessToken: String = runBlocking {
context.settingsDataStore.data.map { settings -> settings.accessToken }.first()
}
if (accessToken.isNotEmpty()) {
appNavController.navigate("main") {
popUpTo("auth") { inclusive = true }
}
}
}
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val onPendingSnackbar: (String) -> Unit = {
scope.launch { snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long) }
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
content = { paddingValues ->
Row(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
AuthForm(
appNavController,
onPendingSnackbar
)
}
})
}

View File

@@ -1,143 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import ru.n08i40k.polytechnic.next.R
@Preview(showBackground = true)
@Composable
internal fun LoginForm(
appNavController: NavHostController = rememberNavController(),
navController: NavHostController = rememberNavController(),
onPendingSnackbar: (String) -> Unit = {}
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
var loading by remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var usernameError by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf(false) }
val onClick = fun() {
focusManager.clearFocus()
if (username.length < 4) usernameError = true
if (password.isEmpty()) passwordError = true
if (usernameError || passwordError) return
loading = true
trySignIn(
context,
username,
password,
{
loading = false
val stringRes = when (it) {
SignInError.INVALID_CREDENTIALS -> {
usernameError = true
passwordError = true
R.string.invalid_credentials
}
SignInError.TIMED_OUT -> R.string.timed_out
SignInError.NO_CONNECTION -> R.string.no_connection
SignInError.APPLICATION_TOO_OLD -> R.string.app_too_old
SignInError.UNKNOWN -> R.string.unknown_error
}
onPendingSnackbar(context.getString(stringRes))
},
{
loading = false
appNavController.navigate("main") {
popUpTo("auth") { inclusive = true }
}
}
)
}
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.sign_in_title),
modifier = Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.size(10.dp))
OutlinedTextField(
value = username,
singleLine = true,
onValueChange = {
username = it
usernameError = false
},
label = { Text(stringResource(R.string.username)) },
isError = usernameError
)
OutlinedTextField(
value = password,
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
onValueChange = {
passwordError = false
password = it
},
label = { Text(stringResource(R.string.password)) },
isError = passwordError
)
TextButton(onClick = { navController.navigate("sign-up") }) {
Text(text = stringResource(R.string.not_registered))
}
Button(
enabled = !loading && !(usernameError || passwordError),
onClick = onClick
) {
Text(
text = stringResource(R.string.proceed),
style = MaterialTheme.typography.bodyLarge
)
}
}
}

View File

@@ -1,177 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.ui.widgets.GroupSelector
import ru.n08i40k.polytechnic.next.ui.widgets.RoleSelector
import ru.n08i40k.polytechnic.next.ui.widgets.TeacherNameSelector
@Preview(showBackground = true)
@Composable
internal fun RegisterForm(
appNavController: NavHostController = rememberNavController(),
navController: NavHostController = rememberNavController(),
onPendingSnackbar: (String) -> Unit = {}
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
var loading by remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var group by remember { mutableStateOf<String?>(null) }
var role by remember { mutableStateOf(UserRole.STUDENT) }
var usernameError by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf(false) }
var groupError by remember { mutableStateOf(false) }
val onClick = fun() {
focusManager.clearFocus()
if (username.length < 4) usernameError = true
if (password.isEmpty()) passwordError = true
if (usernameError || passwordError || groupError) return
loading = true
trySignUp(
context,
username,
password,
group!!,
role,
{
loading = false
val stringRes = when (it) {
SignUpError.UNKNOWN -> R.string.unknown_error
SignUpError.ALREADY_EXISTS -> R.string.already_exists
SignUpError.APPLICATION_TOO_OLD -> R.string.app_too_old
SignUpError.TIMED_OUT -> R.string.timed_out
SignUpError.NO_CONNECTION -> R.string.no_connection
SignUpError.GROUP_DOES_NOT_EXISTS -> R.string.group_does_not_exists
}
onPendingSnackbar(context.getString(stringRes))
},
{
loading = false
appNavController.navigate("main") {
popUpTo("auth") { inclusive = true }
}
}
)
}
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.sign_up_title),
modifier = Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.size(10.dp))
if (role != UserRole.TEACHER) {
OutlinedTextField(
value = username,
singleLine = true,
onValueChange = {
username = it
usernameError = false
},
label = { Text(stringResource(R.string.username)) },
isError = usernameError,
readOnly = loading
)
} else {
TeacherNameSelector(
value = username,
isError = usernameError,
readOnly = loading,
onValueChange = { username = it ?: "" }
)
}
OutlinedTextField(
value = password,
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
onValueChange = {
passwordError = false
password = it
},
label = { Text(stringResource(R.string.password)) },
isError = passwordError,
readOnly = loading
)
Spacer(modifier = Modifier.size(10.dp))
GroupSelector(
value = group,
isError = groupError,
readOnly = loading,
teacher = role == UserRole.TEACHER
) {
groupError = false
group = it
}
Spacer(modifier = Modifier.size(10.dp))
RoleSelector(
value = role,
isError = false,
readOnly = loading
) { role = it }
TextButton(onClick = { navController.navigate("sign-in") }) {
Text(text = stringResource(R.string.already_registered))
}
Button(
enabled = !loading && group != null && !(usernameError || passwordError || groupError),
onClick = onClick
) {
Text(
text = stringResource(R.string.proceed),
style = MaterialTheme.typography.bodyLarge
)
}
}
}

View File

@@ -1,68 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.auth
import android.content.Context
import com.android.volley.AuthFailureError
import com.android.volley.ClientError
import com.android.volley.NoConnectionError
import com.android.volley.TimeoutError
import com.google.firebase.logger.Logger
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignIn
import ru.n08i40k.polytechnic.next.network.unwrapException
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import java.util.concurrent.TimeoutException
internal enum class SignInError {
INVALID_CREDENTIALS,
TIMED_OUT,
NO_CONNECTION,
APPLICATION_TOO_OLD,
UNKNOWN
}
internal fun trySignIn(
context: Context,
username: String,
password: String,
onError: (SignInError) -> Unit,
onSuccess: () -> Unit,
) {
AuthSignIn(AuthSignIn.RequestDto(username, password), context, {
runBlocking {
context.settingsDataStore.updateData { currentSettings ->
currentSettings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.setGroup(it.group)
.build()
}
}
onSuccess()
}, {
val error = when (val exception = unwrapException(it)) {
is TimeoutException -> SignInError.TIMED_OUT
is TimeoutError -> SignInError.TIMED_OUT
is NoConnectionError -> SignInError.NO_CONNECTION
is AuthFailureError -> SignInError.INVALID_CREDENTIALS
is ClientError -> {
if (exception.networkResponse.statusCode == 400)
SignInError.APPLICATION_TOO_OLD
else
SignInError.UNKNOWN
}
else -> SignInError.UNKNOWN
}
if (error == SignInError.UNKNOWN) {
Logger.getLogger("tryLogin")
.error("Unknown exception while trying to login!", it)
}
onError(error)
}).send()
}

View File

@@ -1,78 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.auth
import android.content.Context
import com.android.volley.ClientError
import com.android.volley.NoConnectionError
import com.android.volley.TimeoutError
import com.google.firebase.logger.Logger
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUp
import ru.n08i40k.polytechnic.next.network.unwrapException
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import java.util.concurrent.TimeoutException
internal enum class SignUpError {
ALREADY_EXISTS,
GROUP_DOES_NOT_EXISTS,
TIMED_OUT,
NO_CONNECTION,
APPLICATION_TOO_OLD,
UNKNOWN
}
internal fun trySignUp(
context: Context,
username: String,
password: String,
group: String,
role: UserRole,
onError: (SignUpError) -> Unit,
onSuccess: () -> Unit,
) {
AuthSignUp(
AuthSignUp.RequestDto(
username,
password,
group,
role
), context, {
runBlocking {
context.settingsDataStore.updateData { currentSettings ->
currentSettings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.setGroup(group)
.build()
}
}
onSuccess()
}, {
val error = when (val exception = unwrapException(it)) {
is TimeoutException -> SignUpError.TIMED_OUT
is NoConnectionError -> SignUpError.NO_CONNECTION
is TimeoutError -> SignUpError.UNKNOWN
is ClientError -> {
when (exception.networkResponse.statusCode) {
400 -> SignUpError.APPLICATION_TOO_OLD
404 -> SignUpError.GROUP_DOES_NOT_EXISTS
409 -> SignUpError.ALREADY_EXISTS
else -> SignUpError.UNKNOWN
}
}
else -> SignUpError.UNKNOWN
}
if (error == SignUpError.UNKNOWN) {
Logger.getLogger("tryRegister")
.error("Unknown exception while trying to register!", it)
}
onError(error)
}).send()
}

View File

@@ -0,0 +1,37 @@
package ru.n08i40k.polytechnic.next.ui.helper
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch
typealias PushSnackbar = (String, SnackbarDuration) -> Unit
@Composable
fun SnackbarBox(modifier: Modifier = Modifier, content: @Composable (PushSnackbar) -> Unit) {
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val pushSnackbar: PushSnackbar = { msg, duration ->
coroutineScope.launch { snackbarHostState.showSnackbar(msg, duration = duration) }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
contentWindowInsets = WindowInsets(0),
) {
Box(
modifier
.fillMaxSize()
.padding(it)) { content(pushSnackbar) }
}
}

View File

@@ -0,0 +1,31 @@
package ru.n08i40k.polytechnic.next.ui.helper.data
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
data class InputValue<T>(
var value: T,
val errorCheck: (T) -> Boolean = { false },
private var checkNow: Boolean = false,
var isError: Boolean = false,
) {
init {
if (checkNow)
isError = isError or errorCheck(value)
// проверки после it.apply {}
checkNow = true
}
}
@Composable
fun <T> rememberInputValue(
defaultValue: T,
checkNow: Boolean = false,
errorCheck: (T) -> Boolean = { false }
): MutableState<InputValue<T>> {
return remember { mutableStateOf(InputValue<T>(defaultValue, errorCheck, checkNow)) }
}

View File

@@ -0,0 +1,87 @@
package ru.n08i40k.polytechnic.next.ui.icons.appicons.filled
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.appicons.FilledGroup
val FilledGroup.Vk: ImageVector
get() {
if (_vk != null) {
return _vk!!
}
_vk = Builder(
name = "Vk", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp, viewportWidth
= 101.0f, viewportHeight = 100.0f
).apply {
group {
path(
fill = SolidColor(Color(0xFF0077FF)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero
) {
moveTo(0.5f, 48.0f)
curveTo(0.5f, 25.37f, 0.5f, 14.06f, 7.53f, 7.03f)
curveTo(14.56f, 0.0f, 25.87f, 0.0f, 48.5f, 0.0f)
horizontalLineTo(52.5f)
curveTo(75.13f, 0.0f, 86.44f, 0.0f, 93.47f, 7.03f)
curveTo(100.5f, 14.06f, 100.5f, 25.37f, 100.5f, 48.0f)
verticalLineTo(52.0f)
curveTo(100.5f, 74.63f, 100.5f, 85.94f, 93.47f, 92.97f)
curveTo(86.44f, 100.0f, 75.13f, 100.0f, 52.5f, 100.0f)
horizontalLineTo(48.5f)
curveTo(25.87f, 100.0f, 14.56f, 100.0f, 7.53f, 92.97f)
curveTo(0.5f, 85.94f, 0.5f, 74.63f, 0.5f, 52.0f)
verticalLineTo(48.0f)
close()
}
path(
fill = SolidColor(Color(0xFFffffff)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero
) {
moveTo(53.71f, 72.04f)
curveTo(30.92f, 72.04f, 17.92f, 56.42f, 17.38f, 30.42f)
horizontalLineTo(28.79f)
curveTo(29.17f, 49.5f, 37.58f, 57.58f, 44.25f, 59.25f)
verticalLineTo(30.42f)
horizontalLineTo(55.0f)
verticalLineTo(46.88f)
curveTo(61.58f, 46.17f, 68.5f, 38.67f, 70.83f, 30.42f)
horizontalLineTo(81.58f)
curveTo(79.79f, 40.58f, 72.29f, 48.08f, 66.96f, 51.17f)
curveTo(72.29f, 53.67f, 80.83f, 60.21f, 84.08f, 72.04f)
horizontalLineTo(72.25f)
curveTo(69.71f, 64.13f, 63.38f, 58.0f, 55.0f, 57.17f)
verticalLineTo(72.04f)
horizontalLineTo(53.71f)
close()
}
}
}
.build()
return _vk!!
}
private var _vk: ImageVector? = null
@Preview
@Composable
private fun Preview() {
Box(modifier = Modifier.padding(12.dp)) {
Image(imageVector = FilledGroup.Vk, contentDescription = "")
}
}

View File

@@ -1,38 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Person
import androidx.compose.ui.graphics.vector.ImageVector
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
data class BottomNavItem(
@StringRes val label: Int,
val icon: ImageVector,
val route: String,
val requiredRole: UserRole? = null
)
object Constants {
val bottomNavItem = listOf(
BottomNavItem(R.string.profile, Icons.Filled.AccountCircle, "profile"),
BottomNavItem(R.string.replacer, Icons.Filled.Create, "replacer", UserRole.ADMIN),
BottomNavItem(
R.string.teacher_schedule,
Icons.Filled.Person,
"teacher-main-schedule",
UserRole.TEACHER
),
BottomNavItem(R.string.schedule, Icons.Filled.DateRange, "schedule"),
BottomNavItem(
R.string.teachers,
Icons.Filled.Person,
"teacher-user-schedule",
UserRole.STUDENT
)
)
}

View File

@@ -1,357 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.MainViewModel
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import ru.n08i40k.polytechnic.next.ui.icons.AppIcons
import ru.n08i40k.polytechnic.next.ui.icons.appicons.Filled
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Download
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Telegram
import ru.n08i40k.polytechnic.next.ui.main.profile.ProfileScreen
import ru.n08i40k.polytechnic.next.ui.main.replacer.ReplacerScreen
import ru.n08i40k.polytechnic.next.ui.main.schedule.group.GroupScheduleScreen
import ru.n08i40k.polytechnic.next.ui.main.schedule.teacher.main.TeacherMainScheduleScreen
import ru.n08i40k.polytechnic.next.ui.main.schedule.teacher.user.TeacherUserScheduleScreen
import ru.n08i40k.polytechnic.next.ui.model.GroupScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState
import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel
import ru.n08i40k.polytechnic.next.ui.model.RemoteConfigViewModel
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerViewModel
import ru.n08i40k.polytechnic.next.ui.model.TeacherScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
@Composable
private fun NavHostContainer(
navController: NavHostController,
padding: PaddingValues,
profileViewModel: ProfileViewModel,
groupScheduleViewModel: GroupScheduleViewModel,
teacherScheduleViewModel: TeacherScheduleViewModel,
scheduleReplacerViewModel: ScheduleReplacerViewModel?
) {
val context = LocalContext.current
val profileUiState by profileViewModel.uiState.collectAsStateWithLifecycle()
val profile: Profile? = when (profileUiState) {
is ProfileUiState.NoProfile -> null
is ProfileUiState.HasProfile ->
(profileUiState as ProfileUiState.HasProfile).profile
}
if (profile == null)
return
NavHost(
navController = navController,
startDestination = if (profile.role == UserRole.TEACHER) "teacher-main-schedule" else "schedule",
modifier = Modifier.padding(paddingValues = padding),
enterTransition = {
slideIn(
animationSpec = tween(
400,
delayMillis = 250,
easing = LinearOutSlowInEasing
)
) { fullSize -> IntOffset(0, fullSize.height / 16) } + fadeIn(
animationSpec = tween(
400,
delayMillis = 250,
easing = LinearOutSlowInEasing
)
)
},
exitTransition = {
fadeOut(
animationSpec = tween(
250,
easing = FastOutSlowInEasing
)
)
},
) {
composable("profile") {
ProfileScreen(LocalContext.current.profileViewModel!!) { context.profileViewModel!!.refreshProfile() }
}
composable("schedule") {
GroupScheduleScreen(groupScheduleViewModel) { groupScheduleViewModel.refresh() }
}
composable("teacher-user-schedule") {
TeacherUserScheduleScreen(teacherScheduleViewModel) {
if (it.isNotEmpty()) teacherScheduleViewModel.fetch(
it
)
}
}
composable("teacher-main-schedule") {
TeacherMainScheduleScreen(teacherScheduleViewModel) {
if (it.isNotEmpty()) teacherScheduleViewModel.fetch(
it
)
}
}
if (scheduleReplacerViewModel != null) {
composable("replacer") {
ReplacerScreen(scheduleReplacerViewModel) { scheduleReplacerViewModel.refresh() }
}
}
}
}
private fun openLink(context: Context, link: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)), null)
}
@Composable
private fun LinkButton(
text: String,
icon: ImageVector,
link: String,
enabled: Boolean = true,
badged: Boolean = false,
) {
val context = LocalContext.current
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = { openLink(context, link) },
enabled = enabled,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
BadgedBox(badge = { if (badged) Badge() }) {
Icon(
imageVector = icon,
contentDescription = text
)
}
Spacer(Modifier.width(5.dp))
Text(text)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopNavBar(
remoteConfigViewModel: RemoteConfigViewModel
) {
var dropdownExpanded by remember { mutableStateOf(false) }
val remoteConfigUiState by remoteConfigViewModel.uiState.collectAsStateWithLifecycle()
val packageVersion =
(LocalContext.current.applicationContext as PolytechnicApplication).getAppVersion()
val updateAvailable = remoteConfigUiState.currVersion != packageVersion
TopAppBar(
title = {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
},
actions = {
IconButton(onClick = { dropdownExpanded = true }) {
BadgedBox(badge = { if (updateAvailable) Badge() }) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "top app bar menu"
)
}
}
DropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
Column(modifier = Modifier.wrapContentWidth()) {
LinkButton(
text = stringResource(R.string.download_update),
icon = AppIcons.Filled.Download,
link = remoteConfigUiState.downloadLink,
enabled = updateAvailable,
badged = updateAvailable
)
LinkButton(
text = stringResource(R.string.telegram_channel),
icon = AppIcons.Filled.Telegram,
link = remoteConfigUiState.telegramLink,
)
}
}
}
)
}
@Composable
private fun BottomNavBar(navController: NavHostController, userRole: UserRole) {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Constants.bottomNavItem.forEach {
if (it.requiredRole != null && it.requiredRole != userRole && userRole != UserRole.ADMIN)
return@forEach
NavigationBarItem(
selected = it.route == currentRoute,
onClick = { if (it.route != currentRoute) navController.navigate(it.route) },
icon = {
Icon(
imageVector = it.icon,
contentDescription = stringResource(it.label)
)
},
label = { Text(stringResource(it.label)) })
}
}
}
@Composable
fun MainScreen(
appNavController: NavHostController,
mainViewModel: MainViewModel = hiltViewModel()
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
val accessToken: String = runBlocking {
context.settingsDataStore.data.map { settings -> settings.accessToken }.first()
}
if (accessToken.isEmpty()) appNavController.navigate("auth")
}
// profile view model
val profileViewModel: ProfileViewModel =
viewModel(
factory = ProfileViewModel.provideFactory(
profileRepository = mainViewModel.appContainer.profileRepository,
onUnauthorized = {
appNavController.navigate("auth") {
popUpTo("main") { inclusive = true }
}
})
)
LocalContext.current.profileViewModel = profileViewModel
// remote config view model
val remoteConfigViewModel: RemoteConfigViewModel =
viewModel(
factory = RemoteConfigViewModel.provideFactory(
appContext = LocalContext.current,
remoteConfig = (LocalContext.current.applicationContext as PolytechnicApplication).container.remoteConfig
)
)
// schedule view model
val groupScheduleViewModel =
hiltViewModel<GroupScheduleViewModel>(LocalContext.current as ComponentActivity)
// teacher view model
val teacherScheduleViewModel =
hiltViewModel<TeacherScheduleViewModel>(LocalContext.current as ComponentActivity)
// schedule replacer view model
val profileUiState by profileViewModel.uiState.collectAsStateWithLifecycle()
val profile: Profile? = when (profileUiState) {
is ProfileUiState.NoProfile -> null
is ProfileUiState.HasProfile ->
(profileUiState as ProfileUiState.HasProfile).profile
}
if (profile == null)
return
val scheduleReplacerViewModel: ScheduleReplacerViewModel? =
if (profile.role == UserRole.ADMIN) hiltViewModel(LocalContext.current as ComponentActivity)
else null
// nav controller
val navController = rememberNavController()
Scaffold(
topBar = { TopNavBar(remoteConfigViewModel) },
bottomBar = { BottomNavBar(navController, profile.role) }
) { paddingValues ->
NavHostContainer(
navController,
paddingValues,
profileViewModel,
groupScheduleViewModel,
teacherScheduleViewModel,
scheduleReplacerViewModel
)
}
}

View File

@@ -1,135 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.profile
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.Dialog
import com.android.volley.AuthFailureError
import com.android.volley.ClientError
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.network.request.auth.AuthChangePassword
private enum class ChangePasswordError {
INCORRECT_CURRENT_PASSWORD,
SAME_PASSWORDS
}
private fun tryChangePassword(
context: Context,
oldPassword: String,
newPassword: String,
onError: (ChangePasswordError) -> Unit,
onSuccess: () -> Unit
) {
AuthChangePassword(AuthChangePassword.RequestDto(oldPassword, newPassword), context, {
onSuccess()
}, {
if (it is ClientError && it.networkResponse.statusCode == 409)
onError(ChangePasswordError.SAME_PASSWORDS)
else if (it is AuthFailureError)
onError(ChangePasswordError.INCORRECT_CURRENT_PASSWORD)
else throw it
}).send()
}
@Preview(showBackground = true)
@Composable
internal fun ChangePasswordDialog(
context: Context = LocalContext.current,
profile: Profile = FakeProfileRepository.exampleProfile,
onChange: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
Dialog(onDismissRequest = onDismiss) {
Card {
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
var oldPasswordError by remember { mutableStateOf(false) }
var newPasswordError by remember { mutableStateOf(false) }
var processing by remember { mutableStateOf(false) }
Column(modifier = Modifier.width(IntrinsicSize.Max)) {
val modifier = Modifier.fillMaxWidth()
OutlinedTextField(
modifier = modifier,
value = oldPassword,
isError = oldPasswordError,
onValueChange = {
oldPassword = it
oldPasswordError = it.isEmpty()
},
visualTransformation = PasswordVisualTransformation(),
label = { Text(text = stringResource(R.string.old_password)) },
readOnly = processing
)
OutlinedTextField(
modifier = modifier,
value = newPassword,
isError = newPasswordError,
onValueChange = {
newPassword = it
newPasswordError = it.isEmpty() || newPassword == oldPassword
},
visualTransformation = PasswordVisualTransformation(),
label = { Text(text = stringResource(R.string.new_password)) },
readOnly = processing
)
val focusManager = LocalFocusManager.current
Button(
modifier = modifier,
onClick = {
processing = true
focusManager.clearFocus()
tryChangePassword(
context = context,
oldPassword = oldPassword,
newPassword = newPassword,
onError = {
when (it) {
ChangePasswordError.SAME_PASSWORDS -> {
oldPasswordError = true
newPasswordError = true
}
ChangePasswordError.INCORRECT_CURRENT_PASSWORD -> {
oldPasswordError = true
}
}
processing = false
},
onSuccess = onChange
)
},
enabled = !(newPasswordError || oldPasswordError || processing)
) {
Text(stringResource(R.string.change_password))
}
}
}
}
}

View File

@@ -1,206 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.profile
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import ru.n08i40k.polytechnic.next.ui.model.GroupScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
@Preview(showBackground = true)
@Composable
internal fun ProfileCard(profile: Profile = FakeProfileRepository.exampleProfile) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(modifier = Modifier.padding(20.dp)) {
Card(
colors = CardDefaults.cardColors(
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier
.wrapContentWidth()
.padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val focusManager = LocalFocusManager.current
val context = LocalContext.current
var usernameChanging by remember { mutableStateOf(false) }
var passwordChanging by remember { mutableStateOf(false) }
var groupChanging by remember { mutableStateOf(false) }
TextField(
label = { Text(stringResource(R.string.username)) },
value = profile.username,
leadingIcon = {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = "username"
)
},
readOnly = true,
onValueChange = {},
modifier = Modifier.onFocusChanged {
if (it.isFocused) {
usernameChanging = true
focusManager.clearFocus()
}
},
)
TextField(
label = { Text(stringResource(R.string.password)) },
value = "12345678",
visualTransformation = PasswordVisualTransformation(),
leadingIcon = {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = "password"
)
},
readOnly = true,
onValueChange = {},
modifier = Modifier.onFocusChanged {
if (it.isFocused) {
passwordChanging = true
focusManager.clearFocus()
}
},
)
TextField(
label = { Text(stringResource(R.string.role)) },
value = stringResource(profile.role.stringId),
leadingIcon = {
Icon(
imageVector = profile.role.icon,
contentDescription = "role"
)
},
readOnly = true,
onValueChange = {},
)
TextField(
label = { Text(stringResource(R.string.group)) },
value = profile.group,
leadingIcon = {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "group"
)
},
readOnly = true,
onValueChange = {},
modifier = Modifier.onFocusChanged {
if (it.isFocused) {
groupChanging = true
focusManager.clearFocus()
}
},
)
Button(onClick = {
runBlocking {
context.settingsDataStore.updateData {
it
.toBuilder()
.setGroup("")
.setAccessToken("")
.setUserId("")
.build()
}
}
context.profileViewModel!!.onUnauthorized()
}) {
Text(stringResource(R.string.sign_out))
}
if (passwordChanging) {
ChangePasswordDialog(
context,
profile,
{ passwordChanging = false }
) { passwordChanging = false }
}
if (usernameChanging) {
ChangeUsernameDialog(
context,
profile,
{
usernameChanging = false
context.profileViewModel!!.refreshProfile()
}
) { usernameChanging = false }
}
if (groupChanging) {
val groupScheduleViewModel =
hiltViewModel<GroupScheduleViewModel>(LocalContext.current as ComponentActivity)
ChangeGroupDialog(
context,
profile,
{ group ->
groupChanging = false
runBlocking {
context.settingsDataStore.updateData {
it.toBuilder().setGroup(group).build()
}
(context.applicationContext as PolytechnicApplication)
.container
.networkCacheRepository
.clear()
}
context.profileViewModel!!.refreshProfile {
groupScheduleViewModel.refresh()
}
}
) { groupChanging = false }
}
}
}
}
}
}

View File

@@ -1,245 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.replacer
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MockAppContainer
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.impl.FakeScheduleReplacerRepository
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerUiState
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerViewModel
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun ReplacerScreen(
scheduleReplacerViewModel: ScheduleReplacerViewModel = ScheduleReplacerViewModel(
MockAppContainer(
LocalContext.current
)
),
refresh: () -> Unit = {}
) {
val uiState by scheduleReplacerViewModel.uiState.collectAsStateWithLifecycle()
var uri by remember { mutableStateOf<Uri?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
uri = it
}
UploadFile(scheduleReplacerViewModel, uri) { uri = null }
LoadingContent(
empty = when (uiState) {
is ScheduleReplacerUiState.NoData -> uiState.isLoading
is ScheduleReplacerUiState.HasData -> false
},
loading = uiState.isLoading,
onRefresh = refresh,
verticalArrangement = Arrangement.Top,
content = {
when (uiState) {
is ScheduleReplacerUiState.NoData -> {
if (!uiState.isLoading) {
TextButton(onClick = refresh, modifier = Modifier.fillMaxSize()) {
Text(stringResource(R.string.reload), textAlign = TextAlign.Center)
}
}
}
is ScheduleReplacerUiState.HasData -> {
Column {
Row(modifier = Modifier.fillMaxWidth()) {
ClearButton(Modifier.fillMaxWidth(0.5F)) {
scheduleReplacerViewModel.clear()
}
SetNewButton(Modifier.fillMaxWidth()) {
launcher.launch(arrayOf("application/vnd.ms-excel"))
}
}
ReplacerList((uiState as ScheduleReplacerUiState.HasData).replacers)
}
}
}
}
)
}
@Composable
fun UploadFile(
scheduleReplacerViewModel: ScheduleReplacerViewModel,
uri: Uri?,
onFinish: () -> Unit
) {
if (uri == null)
return
val context = LocalContext.current
val contentResolver = context.contentResolver
// get file name
val query = contentResolver.query(uri, null, null, null, null)
if (query == null) {
onFinish()
return
}
val fileName = query.use { cursor ->
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIdx)
}
// get file type
val fileType: String? = contentResolver.getType(uri)
if (fileType == null) {
onFinish()
return
}
// get file data
val inputStream = contentResolver.openInputStream(uri)
if (inputStream == null) {
onFinish()
return
}
val fileData = inputStream.readBytes()
inputStream.close()
scheduleReplacerViewModel.set(fileName, fileData, fileType)
onFinish()
}
//@Preview(showBackground = true)
//@Composable
//private fun UploadFileDialog(
// opened: Boolean = true,
// onClose: () -> Unit = {}
//) {
// Dialog(onDismissRequest = onClose) {
// Card {
// Button
// }
// }
//}
@Preview(showBackground = true)
@Composable
private fun SetNewButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
Button(modifier = modifier, onClick = onClick) {
val setReplacerText = stringResource(R.string.set_replacer)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(0.dp, 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = setReplacerText)
Text(text = setReplacerText)
Icon(imageVector = Icons.Filled.Add, contentDescription = setReplacerText)
}
}
}
@Preview(showBackground = true)
@Composable
private fun ClearButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
Button(modifier = modifier, onClick = onClick) {
val clearReplacersText = stringResource(R.string.clear_replacers)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(0.dp, 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(imageVector = Icons.Filled.Delete, contentDescription = clearReplacersText)
Text(text = clearReplacersText)
Icon(imageVector = Icons.Filled.Delete, contentDescription = clearReplacersText)
}
}
}
@Preview(showBackground = true)
@Composable
private fun ReplacerElement(replacer: ScheduleReplacer = FakeScheduleReplacerRepository.exampleReplacers[0]) {
Column(
modifier = Modifier.border(
BorderStroke(
Dp.Hairline,
MaterialTheme.colorScheme.inverseSurface
)
)
) {
val modifier = Modifier.fillMaxWidth()
Text(modifier = modifier, textAlign = TextAlign.Center, text = replacer.etag)
Text(modifier = modifier, textAlign = TextAlign.Center, text = buildString {
append(replacer.size)
append(" ")
append(stringResource(R.string.bytes))
})
}
}
@Preview(showBackground = true)
@Composable
fun ReplacerList(replacers: List<ScheduleReplacer> = FakeScheduleReplacerRepository.exampleReplacers) {
Surface {
LazyColumn(
contentPadding = PaddingValues(0.dp, 5.dp),
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
) {
items(replacers) {
ReplacerElement(it)
}
}
}
}

View File

@@ -1,289 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
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.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
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.window.Dialog
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository
import ru.n08i40k.polytechnic.next.model.Lesson
import ru.n08i40k.polytechnic.next.model.LessonType
import ru.n08i40k.polytechnic.next.utils.dayMinutes
import ru.n08i40k.polytechnic.next.utils.fmtAsClock
private enum class LessonTimeFormat {
FROM_TO, ONLY_MINUTES_DURATION
}
@Composable
private fun fmtTime(start: Int, end: Int, format: LessonTimeFormat): ArrayList<String> {
return when (format) {
LessonTimeFormat.FROM_TO -> {
val startClock = start.fmtAsClock()
val endClock = end.fmtAsClock()
arrayListOf(startClock, endClock)
}
LessonTimeFormat.ONLY_MINUTES_DURATION -> {
val duration = end - start
arrayListOf("$duration" + stringResource(R.string.minutes))
}
}
}
@Preview(showBackground = true)
@Composable
fun LessonExtraInfo(
lesson: Lesson = FakeScheduleRepository.exampleGroup.days[0].lessons[0],
mutableExpanded: MutableState<Boolean> = mutableStateOf(true)
) {
Dialog(onDismissRequest = { mutableExpanded.value = false }) {
if (lesson.type === LessonType.BREAK) {
mutableExpanded.value = false
return@Dialog
}
Card {
Column(Modifier.padding(10.dp)) {
Text(lesson.name!!)
for (subGroup in lesson.subGroups) {
val subGroups = buildString {
append("[")
append(subGroup.number)
append("] ")
append(subGroup.teacher)
append(" - ")
append(subGroup.cabinet)
}
Text(subGroups)
}
val duration = buildString {
append(stringResource(R.string.lesson_duration))
append(" - ")
val duration =
lesson.time.end.dayMinutes - lesson.time.start.dayMinutes
append(duration / 60)
append(stringResource(R.string.hours))
append(" ")
append(duration % 60)
append(stringResource(R.string.minutes))
}
Text(duration)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun LessonViewRow(
lesson: Lesson = FakeScheduleRepository.exampleGroup.days[0].lessons[4],
timeFormat: LessonTimeFormat = LessonTimeFormat.FROM_TO,
cardColors: CardColors = CardDefaults.cardColors(),
verticalPadding: Dp = 10.dp,
now: Boolean = true,
) {
val contentColor =
if (timeFormat == LessonTimeFormat.FROM_TO) cardColors.contentColor
else cardColors.disabledContentColor
val rangeSize =
if (lesson.defaultRange == null) 1
else (lesson.defaultRange[1] - lesson.defaultRange[0] + 1) * 2
Box(
if (now) Modifier.border(
BorderStroke(
3.5.dp,
Color(
cardColors.containerColor.red * 0.5F,
cardColors.containerColor.green * 0.5F,
cardColors.containerColor.blue * 0.5F,
1F
)
)
) else Modifier
) {
Row(
modifier = Modifier.padding(10.dp, verticalPadding * rangeSize),
verticalAlignment = Alignment.CenterVertically,
) {
val rangeString = run {
if (lesson.defaultRange == null)
" "
else
buildString {
val same = lesson.defaultRange[0] == lesson.defaultRange[1]
append(if (same) " " else lesson.defaultRange[0])
append(if (same) lesson.defaultRange[0] else "-")
append(if (same) " " else lesson.defaultRange[1])
}
}
// 1-2
Text(
text = rangeString,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = contentColor
)
Column(
modifier = Modifier.fillMaxWidth(0.20f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val formattedTime: ArrayList<String> =
fmtTime(lesson.time.start.dayMinutes, lesson.time.end.dayMinutes, timeFormat)
// 10:20 - 11:40
Text(
text = formattedTime[0],
fontFamily = FontFamily.Monospace,
color = contentColor
)
if (formattedTime.count() > 1) {
Text(
text = formattedTime[1],
fontFamily = FontFamily.Monospace,
color = contentColor
)
}
}
Column(verticalArrangement = Arrangement.Center) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
if (lesson.type.value > LessonType.BREAK.value) {
Text(
text = when (lesson.type) {
LessonType.CONSULTATION -> stringResource(R.string.lesson_type_consultation)
LessonType.INDEPENDENT_WORK -> stringResource(R.string.lesson_type_independent_work)
LessonType.EXAM -> stringResource(R.string.lesson_type_exam)
LessonType.EXAM_WITH_GRADE -> stringResource(R.string.lesson_type_exam_with_grade)
LessonType.EXAM_DEFAULT -> stringResource(R.string.lesson_type_exam_default)
else -> throw Error("Unknown lesson type!")
},
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = contentColor
)
}
Text(
text = lesson.name ?: stringResource(R.string.lesson_break),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = contentColor
)
if (lesson.group != null) {
Text(
text = lesson.group,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = contentColor
)
}
for (subGroup in lesson.subGroups) {
Text(
text = subGroup.teacher,
fontWeight = FontWeight.Thin,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = contentColor
)
}
}
Column(modifier = Modifier.wrapContentWidth()) {
if (lesson.subGroups.size != 1) {
Text(text = "")
if (lesson.group != null)
Text(text = "")
}
for (subGroup in lesson.subGroups) {
Text(
text = subGroup.cabinet,
maxLines = 1,
fontWeight = FontWeight.Thin,
fontFamily = FontFamily.Monospace,
color = contentColor
)
}
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun FreeLessonRow(
lesson: Lesson = FakeScheduleRepository.exampleGroup.days[0].lessons[0],
cardColors: CardColors = CardDefaults.cardColors(),
now: Boolean = true
) {
LessonViewRow(
lesson,
LessonTimeFormat.ONLY_MINUTES_DURATION,
cardColors,
2.5.dp,
now
)
}
@Preview(showBackground = true)
@Composable
fun LessonRow(
lesson: Lesson = FakeScheduleRepository.exampleGroup.days[0].lessons[0],
cardColors: CardColors = CardDefaults.cardColors(),
now: Boolean = true,
) {
LessonViewRow(
lesson,
LessonTimeFormat.FROM_TO,
cardColors,
5.dp,
now
)
}

View File

@@ -1,100 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule.group
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MockAppContainer
import ru.n08i40k.polytechnic.next.ui.main.schedule.DayPager
import ru.n08i40k.polytechnic.next.ui.model.GroupScheduleUiState
import ru.n08i40k.polytechnic.next.ui.model.GroupScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
@Composable
private fun rememberUpdatedLifecycleOwner(): LifecycleOwner {
val lifecycleOwner = LocalLifecycleOwner.current
return remember { lifecycleOwner }
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun GroupScheduleScreen(
groupScheduleViewModel: GroupScheduleViewModel = GroupScheduleViewModel(MockAppContainer(LocalContext.current)),
onRefresh: () -> Unit = {}
) {
val uiState by groupScheduleViewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
delay(120_000)
onRefresh()
}
val lifecycleOwner = rememberUpdatedLifecycleOwner()
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
onRefresh()
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LoadingContent(
empty = when (uiState) {
is GroupScheduleUiState.NoData -> uiState.isLoading
is GroupScheduleUiState.HasData -> false
},
loading = uiState.isLoading,
onRefresh = onRefresh,
verticalArrangement = Arrangement.Top
) {
when (uiState) {
is GroupScheduleUiState.HasData -> {
Column {
val hasData = uiState as GroupScheduleUiState.HasData
UpdateInfo(hasData.lastUpdateAt, hasData.updateDates)
Spacer(Modifier.height(10.dp))
DayPager(hasData.group)
}
}
is GroupScheduleUiState.NoData -> {
if (!uiState.isLoading) {
TextButton(onClick = onRefresh, modifier = Modifier.fillMaxSize()) {
Text(stringResource(R.string.reload), textAlign = TextAlign.Center)
}
}
}
}
}
}

View File

@@ -1,112 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule.teacher.main
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MockAppContainer
import ru.n08i40k.polytechnic.next.ui.main.schedule.DayPager
import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState
import ru.n08i40k.polytechnic.next.ui.model.TeacherScheduleUiState
import ru.n08i40k.polytechnic.next.ui.model.TeacherScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
@Composable
private fun rememberUpdatedLifecycleOwner(): LifecycleOwner {
val lifecycleOwner = LocalLifecycleOwner.current
return remember { lifecycleOwner }
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun TeacherMainScheduleScreen(
teacherScheduleViewModel: TeacherScheduleViewModel = TeacherScheduleViewModel(
MockAppContainer(
LocalContext.current
)
),
fetch: (String) -> Unit = {}
) {
val profileViewModel = LocalContext.current.profileViewModel!!
val profileUiState by profileViewModel.uiState.collectAsStateWithLifecycle()
if (profileUiState is ProfileUiState.NoProfile)
return
val profile = (profileUiState as ProfileUiState.HasProfile).profile
var teacherName = profile.username
val uiState by teacherScheduleViewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
delay(120_000)
fetch(teacherName)
}
val lifecycleOwner = rememberUpdatedLifecycleOwner()
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
fetch(teacherName)
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxSize()) {
LoadingContent(
empty = when (uiState) {
is TeacherScheduleUiState.NoData -> uiState.isLoading
is TeacherScheduleUiState.HasData -> false
},
loading = uiState.isLoading,
) {
when (uiState) {
is TeacherScheduleUiState.HasData -> {
Column {
val hasData = uiState as TeacherScheduleUiState.HasData
DayPager(hasData.teacher)
}
}
is TeacherScheduleUiState.NoData -> {
if (!uiState.isLoading) {
Text(
modifier = Modifier.fillMaxSize(),
text = stringResource(R.string.teacher_not_selected),
textAlign = TextAlign.Center
)
}
}
}
}
}
}

View File

@@ -1,90 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule.teacher.user
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MenuAnchorType
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBox(
title: String,
onSearchAttempt: (String) -> Unit,
variants: List<String>,
) {
var value by remember { mutableStateOf("") }
val searchableVariants =
remember(variants.size) { variants.map { it.replace(" ", "").replace(".", "").lowercase() } }
val filteredVariants = remember(searchableVariants, value) {
searchableVariants.filter { it.contains(value) }
}
var dropdownExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = dropdownExpanded,
onExpandedChange = {}
) {
Row(modifier = Modifier.fillMaxWidth()) {
TextField(
modifier = Modifier
.fillMaxWidth()
.onFocusChanged {
if (it.hasFocus)
dropdownExpanded = true
}
.menuAnchor(MenuAnchorType.PrimaryEditable, true),
label = { Text(title) },
value = value,
onValueChange = {
value = it
dropdownExpanded = true
},
trailingIcon = {
IconButton(onClick = { onSearchAttempt(value) }) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Search"
)
}
},
singleLine = true,
)
}
ExposedDropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
filteredVariants.forEach {
val fullVariant = variants[searchableVariants.indexOf(it)]
DropdownMenuItem(
text = { Text(fullVariant) },
onClick = {
value = fullVariant
onSearchAttempt(value)
dropdownExpanded = false
}
)
}
}
}
}

View File

@@ -1,46 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule.teacher.user
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetTeacherNames
@Composable
private fun getTeacherNames(context: Context): ArrayList<String> {
val teacherNames = remember { arrayListOf<String>() }
LaunchedEffect(teacherNames) {
ScheduleGetTeacherNames(context, {
teacherNames.clear()
teacherNames.addAll(it.names)
}, {
teacherNames.clear()
}).send()
}
return teacherNames
}
@Preview(showBackground = true)
@Composable
fun TeacherSearchBox(
onSearchAttempt: (String) -> Unit = {},
) {
val teachers = getTeacherNames(LocalContext.current)
val focusManager = LocalFocusManager.current
SearchBox(
stringResource(R.string.teacher_name),
{
focusManager.clearFocus(true)
onSearchAttempt(it)
},
teachers,
)
}

View File

@@ -1,114 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule.teacher.user
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MockAppContainer
import ru.n08i40k.polytechnic.next.ui.main.schedule.DayPager
import ru.n08i40k.polytechnic.next.ui.model.TeacherScheduleUiState
import ru.n08i40k.polytechnic.next.ui.model.TeacherScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
@Composable
private fun rememberUpdatedLifecycleOwner(): LifecycleOwner {
val lifecycleOwner = LocalLifecycleOwner.current
return remember { lifecycleOwner }
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun TeacherUserScheduleScreen(
teacherScheduleViewModel: TeacherScheduleViewModel = TeacherScheduleViewModel(
MockAppContainer(
LocalContext.current
)
),
fetch: (String) -> Unit = {}
) {
var teacherName by remember { mutableStateOf("") }
val uiState by teacherScheduleViewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
delay(120_000)
fetch(teacherName)
}
val lifecycleOwner = rememberUpdatedLifecycleOwner()
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
fetch(teacherName)
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxSize()) {
TeacherSearchBox(onSearchAttempt = {
teacherName = it
fetch(it)
})
Spacer(Modifier.height(10.dp))
LoadingContent(
empty = when (uiState) {
is TeacherScheduleUiState.NoData -> uiState.isLoading
is TeacherScheduleUiState.HasData -> false
},
loading = uiState.isLoading,
) {
when (uiState) {
is TeacherScheduleUiState.HasData -> {
Column {
val hasData = uiState as TeacherScheduleUiState.HasData
DayPager(hasData.teacher)
}
}
is TeacherScheduleUiState.NoData -> {
if (!uiState.isLoading) {
Text(
modifier = Modifier.fillMaxSize(),
text = stringResource(R.string.teacher_not_selected),
textAlign = TextAlign.Center
)
}
}
}
}
}
}

View File

@@ -10,77 +10,76 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.UpdateDates import ru.n08i40k.polytechnic.next.UpdateDates
import ru.n08i40k.polytechnic.next.data.AppContainer import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.utils.MyResult
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
sealed interface GroupScheduleUiState { sealed interface GroupUiState {
val isLoading: Boolean val isLoading: Boolean
data class NoData( data class NoData(
override val isLoading: Boolean override val isLoading: Boolean
) : GroupScheduleUiState ) : GroupUiState
data class HasData( data class HasData(
val group: GroupOrTeacher, val group: GroupOrTeacher,
val updateDates: UpdateDates, val updateDates: UpdateDates,
val lastUpdateAt: Long, val lastUpdateAt: Long,
override val isLoading: Boolean override val isLoading: Boolean
) : GroupScheduleUiState ) : GroupUiState
} }
private data class GroupScheduleViewModelState( private data class GroupViewModelState(
val group: GroupOrTeacher? = null, val group: GroupOrTeacher? = null,
val updateDates: UpdateDates? = null, val updateDates: UpdateDates? = null,
val lastUpdateAt: Long = 0, val lastUpdateAt: Long = 0,
val isLoading: Boolean = false val isLoading: Boolean = false
) { ) {
fun toUiState(): GroupScheduleUiState = if (group == null) { fun toUiState(): GroupUiState =
GroupScheduleUiState.NoData(isLoading) if (group == null)
} else { GroupUiState.NoData(isLoading)
GroupScheduleUiState.HasData(group, updateDates!!, lastUpdateAt, isLoading) else
} GroupUiState.HasData(group, updateDates!!, lastUpdateAt, isLoading)
} }
@HiltViewModel @HiltViewModel
class GroupScheduleViewModel @Inject constructor( class GroupViewModel @Inject constructor(
appContainer: AppContainer appContainer: AppContainer
) : ViewModel() { ) : ViewModel() {
private val scheduleRepository = appContainer.scheduleRepository private val scheduleRepository = appContainer.scheduleRepository
private val networkCacheRepository = appContainer.networkCacheRepository private val networkCacheRepository = appContainer.networkCacheRepository
private val viewModelState = MutableStateFlow(GroupScheduleViewModelState(isLoading = true))
val uiState = viewModelState private val state = MutableStateFlow(GroupViewModelState(isLoading = true))
.map(GroupScheduleViewModelState::toUiState)
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) val uiState = state
.map(GroupViewModelState::toUiState)
.stateIn(viewModelScope, SharingStarted.Companion.Eagerly, state.value.toUiState())
init { init {
refresh() refresh()
} }
fun refresh() { fun refresh() {
viewModelState.update { it.copy(isLoading = true) } state.update { it.copy(isLoading = true) }
viewModelScope.launch { viewModelScope.launch {
val result = scheduleRepository.getGroup() val result = scheduleRepository.getGroup()
viewModelState.update { state.update {
when (result) { when (result) {
is MyResult.Success -> { is MyResult.Success -> it.copy(
val updateDates = networkCacheRepository.getUpdateDates()
it.copy(
group = result.data, group = result.data,
updateDates = updateDates, updateDates = networkCacheRepository.getUpdateDates(),
lastUpdateAt = Date().time, lastUpdateAt = Date().time,
isLoading = false isLoading = false
) )
}
is MyResult.Failure -> it.copy( is MyResult.Failure -> it.copy(
group = null, group = null,
updateDates = null,
lastUpdateAt = 0,
isLoading = false isLoading = false
) )
} }

View File

@@ -1,32 +1,30 @@
package ru.n08i40k.polytechnic.next.ui.model package ru.n08i40k.polytechnic.next.ui.model
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.data.users.ProfileRepository
import ru.n08i40k.polytechnic.next.model.Profile import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.utils.MyResult
import ru.n08i40k.polytechnic.next.utils.SingleHook
import javax.inject.Inject
sealed interface ProfileUiState { sealed interface ProfileUiState {
val isLoading: Boolean val isLoading: Boolean
data class NoProfile( data class NoData(
override val isLoading: Boolean override val isLoading: Boolean
) : ProfileUiState ) : ProfileUiState
data class HasProfile( data class HasData(
val profile: Profile, override val isLoading: Boolean,
override val isLoading: Boolean val profile: Profile
) : ProfileUiState ) : ProfileUiState
} }
@@ -34,59 +32,53 @@ private data class ProfileViewModelState(
val profile: Profile? = null, val profile: Profile? = null,
val isLoading: Boolean = false val isLoading: Boolean = false
) { ) {
fun toUiState(): ProfileUiState = if (profile == null) { fun toUiState(): ProfileUiState = when (profile) {
ProfileUiState.NoProfile(isLoading) null -> ProfileUiState.NoData(isLoading)
} else { else -> ProfileUiState.HasData(isLoading, profile)
ProfileUiState.HasProfile(profile, isLoading)
} }
} }
@HiltViewModel
class ProfileViewModel( class ProfileViewModel @Inject constructor(
private val profileRepository: ProfileRepository, appContainer: AppContainer
val onUnauthorized: () -> Unit
) : ViewModel() { ) : ViewModel() {
private val viewModelState = MutableStateFlow(ProfileViewModelState(isLoading = true)) private val repository = appContainer.profileRepository
val uiState = viewModelState private val state = MutableStateFlow(ProfileViewModelState(isLoading = true))
val uiState = state
.map(ProfileViewModelState::toUiState) .map(ProfileViewModelState::toUiState)
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) .stateIn(viewModelScope, SharingStarted.Eagerly, state.value.toUiState())
init { init {
refreshProfile() refresh()
} }
fun refreshProfile(callback: () -> Unit = {}) { // TODO: сделать хук на unauthorized и сделать так что бы waiter удалялся, если сход контекст
viewModelState.update { it.copy(isLoading = true) }
fun refresh(): SingleHook<Profile?> {
val singleHook = SingleHook<Profile?>()
state.update { it.copy(isLoading = true) }
viewModelScope.launch { viewModelScope.launch {
val result = profileRepository.getProfile() repository.getProfile().let { result ->
state.update {
viewModelState.update {
when (result) { when (result) {
is MyResult.Success -> it.copy(profile = result.data, isLoading = false) is MyResult.Failure -> it.copy(null, false)
is MyResult.Failure -> it.copy(profile = null, isLoading = false) is MyResult.Success -> it.copy(result.data, false)
} }
} }
callback() singleHook.resolve(
if (result is MyResult.Success)
result.data
else
null
)
} }
} }
companion object { return singleHook
fun provideFactory(
profileRepository: ProfileRepository,
onUnauthorized: () -> Unit
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST") return ProfileViewModel(
profileRepository,
onUnauthorized
) as T
} }
} }
}
}
var Context.profileViewModel: ProfileViewModel? by mutableStateOf(null)

View File

@@ -1,19 +1,18 @@
package ru.n08i40k.polytechnic.next.ui.model package ru.n08i40k.polytechnic.next.ui.model
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.firebase.remoteconfig.ConfigUpdate import com.google.firebase.remoteconfig.ConfigUpdate
import com.google.firebase.remoteconfig.ConfigUpdateListener import com.google.firebase.remoteconfig.ConfigUpdateListener
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.FirebaseRemoteConfigException import com.google.firebase.remoteconfig.FirebaseRemoteConfigException
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import ru.n08i40k.polytechnic.next.ui.MainActivity import ru.n08i40k.polytechnic.next.app.AppContainer
import java.util.logging.Logger import java.util.logging.Logger
import javax.inject.Inject
data class RemoteConfigUiState( data class RemoteConfigUiState(
val minVersion: String, val minVersion: String,
@@ -24,12 +23,13 @@ data class RemoteConfigUiState(
val linkUpdateDelay: Long, val linkUpdateDelay: Long,
) )
@HiltViewModel
class RemoteConfigViewModel( class RemoteConfigViewModel @Inject constructor(
private val appContext: Context, appContainer: AppContainer
private val remoteConfig: FirebaseRemoteConfig,
) : ViewModel() { ) : ViewModel() {
private val viewModelState = MutableStateFlow( private val remoteConfig = appContainer.remoteConfig
private val state = MutableStateFlow(
RemoteConfigUiState( RemoteConfigUiState(
minVersion = remoteConfig.getString("minVersion"), minVersion = remoteConfig.getString("minVersion"),
currVersion = remoteConfig.getString("currVersion"), currVersion = remoteConfig.getString("currVersion"),
@@ -40,17 +40,14 @@ class RemoteConfigViewModel(
) )
) )
val uiState = viewModelState val uiState = state
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value) .stateIn(viewModelScope, SharingStarted.Eagerly, state.value)
init { init {
(appContext as MainActivity)
.scheduleLinkUpdate(viewModelState.value.linkUpdateDelay)
remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener {
override fun onUpdate(configUpdate: ConfigUpdate) { override fun onUpdate(configUpdate: ConfigUpdate) {
remoteConfig.activate().addOnCompleteListener { remoteConfig.activate().addOnCompleteListener {
viewModelState.update { state.update {
it.copy( it.copy(
minVersion = remoteConfig.getString("minVersion"), minVersion = remoteConfig.getString("minVersion"),
currVersion = remoteConfig.getString("currVersion"), currVersion = remoteConfig.getString("currVersion"),
@@ -60,8 +57,6 @@ class RemoteConfigViewModel(
linkUpdateDelay = remoteConfig.getLong("linkUpdateDelay"), linkUpdateDelay = remoteConfig.getLong("linkUpdateDelay"),
) )
} }
appContext.scheduleLinkUpdate(viewModelState.value.linkUpdateDelay)
} }
} }
@@ -71,19 +66,4 @@ class RemoteConfigViewModel(
} }
}) })
} }
companion object {
fun provideFactory(
appContext: Context,
remoteConfig: FirebaseRemoteConfig,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST") return RemoteConfigViewModel(
appContext,
remoteConfig,
) as T
}
}
}
} }

View File

@@ -1,115 +0,0 @@
package ru.n08i40k.polytechnic.next.ui.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.data.AppContainer
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
import javax.inject.Inject
sealed interface ScheduleReplacerUiState {
val isLoading: Boolean
data class NoData(
override val isLoading: Boolean,
) : ScheduleReplacerUiState
data class HasData(
override val isLoading: Boolean,
val replacers: List<ScheduleReplacer>,
) : ScheduleReplacerUiState
}
private data class ScheduleReplacerViewModelState(
val isLoading: Boolean = false,
val replacers: List<ScheduleReplacer>? = null,
) {
fun toUiState(): ScheduleReplacerUiState =
if (replacers == null)
ScheduleReplacerUiState.NoData(isLoading)
else
ScheduleReplacerUiState.HasData(isLoading, replacers)
}
@HiltViewModel
class ScheduleReplacerViewModel @Inject constructor(
appContainer: AppContainer
) : ViewModel() {
private val scheduleReplacerRepository = appContainer.scheduleReplacerRepository
private val viewModelState = MutableStateFlow(ScheduleReplacerViewModelState(isLoading = true))
val uiState = viewModelState
.map(ScheduleReplacerViewModelState::toUiState)
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState())
init {
refresh()
}
fun refresh() {
setLoading()
viewModelScope.launch { update() }
}
fun set(
fileName: String,
fileData: ByteArray,
fileType: String
) {
setLoading()
viewModelScope.launch {
val result = scheduleReplacerRepository.setCurrent(fileName, fileData, fileType)
if (result is MyResult.Success) update()
else setLoading(false)
}
}
fun clear() {
setLoading()
viewModelScope.launch {
val result = scheduleReplacerRepository.clear()
viewModelState.update {
when (result) {
is MyResult.Failure -> it.copy(isLoading = false)
is MyResult.Success -> it.copy(isLoading = false, replacers = emptyList())
}
}
}
}
private fun setLoading(loading: Boolean = true) {
viewModelState.update { it.copy(isLoading = loading) }
}
private suspend fun update() {
val result = scheduleReplacerRepository.getAll()
viewModelState.update {
when (result) {
is MyResult.Success -> {
it.copy(
replacers = result.data,
isLoading = false
)
}
is MyResult.Failure -> it.copy(
replacers = null,
isLoading = false
)
}
}
}
}

View File

@@ -10,87 +10,92 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.UpdateDates import ru.n08i40k.polytechnic.next.UpdateDates
import ru.n08i40k.polytechnic.next.data.AppContainer import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.utils.MyResult
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
sealed interface TeacherScheduleUiState { sealed interface SearchUiState {
val isLoading: Boolean val isLoading: Boolean
data class NoData( data class NoData(
override val isLoading: Boolean override val isLoading: Boolean
) : TeacherScheduleUiState ) : SearchUiState
data class HasData( data class HasData(
val teacher: GroupOrTeacher, val teacher: GroupOrTeacher,
val updateDates: UpdateDates, val updateDates: UpdateDates,
val lastUpdateAt: Long, val lastUpdateAt: Long,
override val isLoading: Boolean override val isLoading: Boolean
) : TeacherScheduleUiState ) : SearchUiState
} }
private data class TeacherScheduleViewModelState( private data class SearchViewModelState(
val teacher: GroupOrTeacher? = null, val teacher: GroupOrTeacher? = null,
val updateDates: UpdateDates? = null, val updateDates: UpdateDates? = null,
val lastUpdateAt: Long = 0, val lastUpdateAt: Long = 0,
val isLoading: Boolean = false val isLoading: Boolean = false
) { ) {
fun toUiState(): TeacherScheduleUiState = if (teacher == null) { fun toUiState(): SearchUiState =
TeacherScheduleUiState.NoData(isLoading) if (teacher == null) SearchUiState.NoData(isLoading)
} else { else SearchUiState.HasData(teacher, updateDates!!, lastUpdateAt, isLoading)
TeacherScheduleUiState.HasData(teacher, updateDates!!, lastUpdateAt, isLoading)
}
} }
@HiltViewModel @HiltViewModel
class TeacherScheduleViewModel @Inject constructor( class SearchViewModel @Inject constructor(
appContainer: AppContainer appContainer: AppContainer
) : ViewModel() { ) : ViewModel() {
private val scheduleRepository = appContainer.scheduleRepository private val scheduleRepository = appContainer.scheduleRepository
private val networkCacheRepository = appContainer.networkCacheRepository private val networkCacheRepository = appContainer.networkCacheRepository
private val viewModelState = MutableStateFlow(TeacherScheduleViewModelState(isLoading = true))
val uiState = viewModelState private val state = MutableStateFlow(SearchViewModelState(isLoading = true))
.map(TeacherScheduleViewModelState::toUiState)
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) val uiState = state
.map(SearchViewModelState::toUiState)
.stateIn(viewModelScope, SharingStarted.Eagerly, state.value.toUiState())
private var teacherName: String? = null
init { init {
fetch(null) refresh()
} }
fun fetch(name: String?) { fun set(name: String?) {
if (name == null) { teacherName = name
viewModelState.update { refresh()
}
fun refresh() {
state.update { it.copy(isLoading = true) }
if (teacherName == null) {
state.update {
it.copy( it.copy(
teacher = null, teacher = null,
updateDates = null,
lastUpdateAt = 0,
isLoading = false isLoading = false
) )
} }
return return
} }
viewModelState.update { it.copy(isLoading = true) }
viewModelScope.launch { viewModelScope.launch {
val result = scheduleRepository.getTeacher(name) scheduleRepository.getTeacher(teacherName!!).let { result ->
state.update {
viewModelState.update {
when (result) { when (result) {
is MyResult.Success -> { is MyResult.Success -> it.copy(
val updateDates = networkCacheRepository.getUpdateDates()
it.copy(
teacher = result.data, teacher = result.data,
updateDates = updateDates, updateDates = networkCacheRepository.getUpdateDates(),
lastUpdateAt = Date().time, lastUpdateAt = Date().time,
isLoading = false isLoading = false
) )
}
is MyResult.Failure -> it.copy( is MyResult.Failure -> it.copy(
teacher = null, teacher = null,
updateDates = null,
lastUpdateAt = 0,
isLoading = false isLoading = false
) )
} }
@@ -98,3 +103,4 @@ class TeacherScheduleViewModel @Inject constructor(
} }
} }
} }
}

View File

@@ -0,0 +1,87 @@
package ru.n08i40k.polytechnic.next.ui.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.UpdateDates
import ru.n08i40k.polytechnic.next.app.AppContainer
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.utils.MyResult
import javax.inject.Inject
sealed interface TeacherUiState {
val isLoading: Boolean
data class NoData(
override val isLoading: Boolean
) : TeacherUiState
data class HasData(
val teacher: GroupOrTeacher,
val updateDates: UpdateDates,
val lastUpdateAt: Long,
override val isLoading: Boolean
) : TeacherUiState
}
private data class TeacherViewModelState(
val teacher: GroupOrTeacher? = null,
val updateDates: UpdateDates? = null,
val lastUpdateAt: Long = 0,
val isLoading: Boolean = false
) {
fun toUiState(): TeacherUiState = when (teacher) {
null -> TeacherUiState.NoData(isLoading)
else -> TeacherUiState.HasData(teacher, updateDates!!, lastUpdateAt, isLoading)
}
}
@HiltViewModel
class TeacherViewModel @Inject constructor(
appContainer: AppContainer
) : ViewModel() {
private val scheduleRepository = appContainer.scheduleRepository
private val networkCacheRepository = appContainer.networkCacheRepository
private val state = MutableStateFlow(TeacherViewModelState(isLoading = true))
val uiState = state
.map { it.toUiState() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), state.value.toUiState())
init {
refresh()
}
fun refresh() {
state.update { it.copy(isLoading = true) }
viewModelScope.launch {
scheduleRepository.getTeacher("self").let { result ->
state.update {
when (result) {
is MyResult.Success -> it.copy(
teacher = result.data,
updateDates = networkCacheRepository.getUpdateDates(),
lastUpdateAt = System.currentTimeMillis(),
isLoading = false
)
is MyResult.Failure -> it.copy(
teacher = null,
updateDates = null,
lastUpdateAt = 0,
isLoading = false
)
}
}
}
}
}
}

View File

@@ -0,0 +1,37 @@
package ru.n08i40k.polytechnic.next.ui.navigation
import androidx.annotation.StringRes
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
data class BottomNavItem(
@StringRes val label: Int,
val icon: ImageVector,
val route: String,
)
@Composable
fun BottomNavBar(navHostController: NavHostController, items: List<BottomNavItem>) {
NavigationBar {
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
items.forEach {
NavigationBarItem(
selected = it.route == currentRoute,
onClick = { if (it.route != currentRoute) navHostController.navigate(it.route) },
icon = { Icon(it.icon, stringResource(it.label)) },
label = { Text(stringResource(it.label)) }
)
}
}
}

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