diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index f866a66..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -PolytecnicNext \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index bce97c3..4bec4ea 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,9 +1,5 @@ - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml index 79ee123..a55e7a1 100644 --- a/.idea/codeStyles/codeStyleConfig.xml +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -1,5 +1,5 @@ - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b86273d..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index e21cb14..8f96cb6 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -2,16 +2,8 @@ - + diff --git a/.idea/dictionaries/n08i40k.xml b/.idea/dictionaries/n08i40k.xml deleted file mode 100644 index 24d5649..0000000 --- a/.idea/dictionaries/n08i40k.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml deleted file mode 100644 index 912db82..0000000 --- a/.idea/discord.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 1dbfe25..18df225 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,5 +1,8 @@ + + diff --git a/.idea/misc.xml b/.idea/misc.xml index 5bb575c..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,11 +1,4 @@ - - - - - - - diff --git a/LICENSE b/LICENSE deleted file mode 100644 index aad2a64..0000000 --- a/LICENSE +++ /dev/null @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 414f30a..1b976a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,11 +1,11 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import com.google.protobuf.gradle.id import com.google.protobuf.gradle.proto - plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.compose) kotlin("plugin.serialization") version "2.0.20" id("kotlin-parcelize") @@ -18,9 +18,22 @@ plugins { alias(libs.plugins.google.firebase.crashlytics) id("com.google.dagger.hilt.android") + + id("vkid.manifest.placeholders") version "1.1.0" apply true } +val localProperties = gradleLocalProperties(rootDir, providers) + 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" compileSdk = 35 @@ -33,13 +46,10 @@ android { applicationId = "ru.n08i40k.polytechnic.next" minSdk = 26 targetSdk = 35 - versionCode = 23 - versionName = "2.3.0" + versionCode = 25 + versionName = "3.0.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } } buildTypes { @@ -49,11 +59,14 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + signingConfig = signingConfigs.getByName("release") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + + isCoreLibraryDesugaringEnabled = true } kotlinOptions { jvmTarget = "11" @@ -64,11 +77,6 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.1" } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } sourceSets { getByName("main") { @@ -90,6 +98,15 @@ android { } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) + + // vk + implementation(libs.vk.vkid) + implementation(libs.vk.onetap.compose) + + // internet + implementation(libs.volley) + // work manager implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime.ktx) @@ -116,6 +133,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.datetime) + // default implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -124,13 +142,10 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - implementation(libs.volley) implementation(libs.androidx.navigation.compose) + // test testImplementation(libs.junit) - testImplementation(libs.mockito.kotlin) - - androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) @@ -157,4 +172,4 @@ protobuf { } } } -} +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8a0dc3d..9c54e9a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,27 @@ # If you keep the line number information, uncomment this to # hide the original source file name. --renamesourcefileattribute SourceFile \ No newline at end of file +-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 { *; } \ No newline at end of file diff --git a/app/src/androidTest/java/ru/n08i40k/polytechnic/next/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ru/n08i40k/polytechnic/next/ExampleInstrumentedTest.kt index b4f211d..eefb4f0 100644 --- a/app/src/androidTest/java/ru/n08i40k/polytechnic/next/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/ru/n08i40k/polytechnic/next/ExampleInstrumentedTest.kt @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("ru.n08i40k.polytecnic.next", appContext.packageName) + assertEquals("ru.n08i40k.polytechnic.next", appContext.packageName) } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 75b9177..457e895 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,48 +2,40 @@ - + - + - + - - - - + android:theme="@style/Theme.Polytechnic"> diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index 9630d62..0000000 Binary files a/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/Application.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/Application.kt new file mode 100644 index 0000000..d002d27 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/Application.kt @@ -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 = Observable() +) + +@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 { + override fun onComplete(token: Task) { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/MainActivity.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/MainActivity.kt new file mode 100644 index 0000000..02354fd --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/MainActivity.kt @@ -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() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/MainViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/MainViewModel.kt deleted file mode 100644 index 4700123..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/MainViewModel.kt +++ /dev/null @@ -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() \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/PolytechnicApplication.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/PolytechnicApplication.kt deleted file mode 100644 index 1da63bf..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/PolytechnicApplication.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/app/AppContainer.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/app/AppContainer.kt new file mode 100644 index 0000000..be3265c --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/app/AppContainer.kt @@ -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 diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/NotificationChannels.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/app/NotificationChannels.kt similarity index 61% rename from app/src/main/java/ru/n08i40k/polytechnic/next/NotificationChannels.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/app/NotificationChannels.kt index 9cf4077..b221a47 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/NotificationChannels.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/app/NotificationChannels.kt @@ -1,7 +1,6 @@ -package ru.n08i40k.polytechnic.next +package ru.n08i40k.polytechnic.next.app object NotificationChannels { - const val LESSON_VIEW = "lesson-view" const val SCHEDULE_UPDATE = "schedule-update" const val APP_UPDATE = "app-update" } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/AppContainer.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/data/AppContainer.kt deleted file mode 100644 index 613b8e1..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/AppContainer.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/MyResult.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/data/MyResult.kt deleted file mode 100644 index 0876334..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/MyResult.kt +++ /dev/null @@ -1,6 +0,0 @@ -package ru.n08i40k.polytechnic.next.data - -sealed interface MyResult { - data class Success(val data: T) : MyResult - data class Failure(val exception: Exception) : MyResult -} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/ScheduleReplacerRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/ScheduleReplacerRepository.kt deleted file mode 100644 index 9d22296..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/ScheduleReplacerRepository.kt +++ /dev/null @@ -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> - - suspend fun setCurrent( - fileName: String, - fileData: ByteArray, - fileType: String - ): MyResult - - suspend fun clear(): MyResult -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/impl/FakeScheduleReplacerRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/impl/FakeScheduleReplacerRepository.kt deleted file mode 100644 index 8c083ed..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/impl/FakeScheduleReplacerRepository.kt +++ /dev/null @@ -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 = listOf( - ScheduleReplacer("test-etag", 236 * 1024), - ScheduleReplacer("frgsjkfhg", 623 * 1024), - ) - } - - override suspend fun getAll(): MyResult> { - return MyResult.Success(exampleReplacers) - } - - override suspend fun setCurrent( - fileName: String, - fileData: ByteArray, - fileType: String - ): MyResult { - return MyResult.Success(Unit) - } - - override suspend fun clear(): MyResult { - return MyResult.Success(1) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/impl/RemoteScheduleReplacerRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/impl/RemoteScheduleReplacerRepository.kt deleted file mode 100644 index 70cd290..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/scheduleReplacer/impl/RemoteScheduleReplacerRepository.kt +++ /dev/null @@ -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> = - withContext(Dispatchers.IO) { - tryFuture { ScheduleReplacerGet(context, it, it) } - } - - - override suspend fun setCurrent( - fileName: String, - fileData: ByteArray, - fileType: String - ): MyResult = - withContext(Dispatchers.IO) { - tryFuture { ScheduleReplacerSet(context, fileName, fileData, fileType, it, it) } - } - - override suspend fun clear(): MyResult { - 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) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/ProfileRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/ProfileRepository.kt deleted file mode 100644 index 4d08f12..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/ProfileRepository.kt +++ /dev/null @@ -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 - - suspend fun setFcmToken(token: String): MyResult -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/FakeProfileRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/FakeProfileRepository.kt deleted file mode 100644 index cf03dfe..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/FakeProfileRepository.kt +++ /dev/null @@ -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 { - return withContext(Dispatchers.IO) { - delay(1500) - - if (counter++ % 3 == 0) - MyResult.Failure(Exception()) - else - MyResult.Success(exampleProfile) - } - } - - override suspend fun setFcmToken(token: String): MyResult { - return MyResult.Success(Unit) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/RemoteProfileRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/RemoteProfileRepository.kt deleted file mode 100644 index c3606d3..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/RemoteProfileRepository.kt +++ /dev/null @@ -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 = - withContext(Dispatchers.IO) { - tryFuture { ProfileMe(context, it, it) } - } - - override suspend fun setFcmToken(token: String): MyResult = - withContext(Dispatchers.IO) { - tryFuture { FcmSetToken(context, token, it, it) } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/model/Day.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Day.kt index b3d5ba2..c227666 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/model/Day.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Day.kt @@ -26,8 +26,8 @@ data class Day( val street: String? = null ) : Parcelable { - constructor(name: String, date: Instant, lessons: List) : this( - name, date.toEpochMilliseconds(), lessons + constructor(name: String, date: Instant, lessons: List, street: String?) : this( + name, date.toEpochMilliseconds(), lessons, street ) val date: Instant diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/model/GroupOrTeacher.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/model/GroupOrTeacher.kt index afcab34..41f154a 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/model/GroupOrTeacher.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/model/GroupOrTeacher.kt @@ -3,6 +3,7 @@ package ru.n08i40k.polytechnic.next.model import android.os.Parcelable import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import ru.n08i40k.polytechnic.next.utils.dateTime import java.util.Calendar @Suppress("MemberVisibilityCanBePrivate") @@ -14,12 +15,16 @@ data class GroupOrTeacher( ) : Parcelable { val currentIdx: Int? 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 currentDay + return days.indexOf(day[0]) } val current: Day? @@ -27,6 +32,8 @@ data class GroupOrTeacher( return days.getOrNull(currentIdx ?: return null) } + // TODO: вернуть + @Suppress("unused") val currentKV: Pair? get() { val idx = currentIdx ?: return null diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/model/Lesson.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Lesson.kt index 4defb88..323b531 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/model/Lesson.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Lesson.kt @@ -18,42 +18,32 @@ data class Lesson( val group: String? = null, val subGroups: List ) : Parcelable { - val duration: Int - get() { - val startMinutes = time.start.dayMinutes - val endMinutes = time.end.dayMinutes - - return endMinutes - startMinutes - } + // TODO: вернуть + @Suppress("unused") + val duration get() = time.end.dayMinutes - time.start.dayMinutes + @Suppress("unused") fun getNameAndCabinetsShort(context: Context): String { 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 - 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()) - return limitedName + if (cabinetList.size == 1 && cabinetList[0] == "с/з") + return "$shortName ${context.getString(R.string.in_gym_lc)}" - if (cabinets.size == 1 && cabinets[0] == "с/з") - return buildString { - append(limitedName) - 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(", ") - ) - ) - } + val cabinets = + context.getString(R.string.in_cabinets_short_lc, cabinetList.joinToString(", ")) + return "$shortName $cabinets" } } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/model/Profile.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Profile.kt index f34eb01..f9dd0e2 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/model/Profile.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Profile.kt @@ -5,8 +5,9 @@ import kotlinx.serialization.Serializable @Serializable data class Profile( val id: String, - val accessToken: String, val username: String, val group: String, - val role: UserRole + val role: UserRole, + val accessToken: String? = null, + val vkId: Int? = null ) diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/NetworkValues.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/NetworkValues.kt index c35d2f4..106cf81 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/NetworkValues.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/NetworkValues.kt @@ -1,5 +1,5 @@ package ru.n08i40k.polytechnic.next.network object NetworkValues { - const val API_HOST = "https://polytechnic.n08i40k.ru:5050/api/" + const val API_HOST = "https://polytechnic.n08i40k.ru/api/" } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/RequestBase.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/RequestBase.kt index c4622d2..df16e05 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/RequestBase.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/RequestBase.kt @@ -6,13 +6,12 @@ import com.android.volley.toolbox.StringRequest import java.util.logging.Logger open class RequestBase( - protected val context: Context, method: Int, url: String?, listener: Response.Listener, errorListener: Response.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") NetworkConnection.getInstance(context).addToRequestQueue(this) } diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/RequestUtils.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/RequestUtils.kt index 6e7cdc8..7290ce1 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/RequestUtils.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/RequestUtils.kt @@ -1,17 +1,19 @@ package ru.n08i40k.polytechnic.next.network +import android.content.Context import com.android.volley.VolleyError 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.TimeoutException fun tryFuture( + context: Context, buildRequest: (RequestFuture) -> RequestT ): MyResult { val future = RequestFuture.newFuture() - buildRequest(future).send() + buildRequest(future).send(context) return tryGet(future) } @@ -31,6 +33,6 @@ fun tryGet(future: RequestFuture): MyResult { fun unwrapException(exception: Exception): Throwable { if (exception is ExecutionException && exception.cause != null) return exception.cause!! - + return exception } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/AuthorizedMultipartRequest.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/AuthorizedMultipartRequest.kt similarity index 94% rename from app/src/main/java/ru/n08i40k/polytechnic/next/network/AuthorizedMultipartRequest.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/network/request/AuthorizedMultipartRequest.kt index 3ac758c..1e1716d 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/AuthorizedMultipartRequest.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/AuthorizedMultipartRequest.kt @@ -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 ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest +import ru.n08i40k.polytechnic.next.app.AppContainer import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataOutputStream @@ -11,13 +10,13 @@ import java.io.UnsupportedEncodingException import kotlin.math.min open class AuthorizedMultipartRequest( - context: Context, + appContainer: AppContainer, method: Int, url: String, listener: Response.Listener, errorListener: Response.ErrorListener?, canBeUnauthorized: Boolean = false -) : AuthorizedRequest(context, method, url, listener, errorListener, canBeUnauthorized) { +) : AuthorizedRequest(appContainer, method, url, listener, errorListener, canBeUnauthorized) { private val twoHyphens = "--" private val lineEnd = "\r\n" private val boundary = "apiclient-" + System.currentTimeMillis() diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/AuthorizedRequest.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/AuthorizedRequest.kt index 60ae574..9e086d2 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/AuthorizedRequest.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/AuthorizedRequest.kt @@ -1,52 +1,72 @@ package ru.n08i40k.polytechnic.next.network.request -import android.content.Context import com.android.volley.AuthFailureError import com.android.volley.Response +import com.android.volley.VolleyError +import jakarta.inject.Singleton 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.network.RequestBase -import ru.n08i40k.polytechnic.next.settings.settingsDataStore -import ru.n08i40k.polytechnic.next.ui.model.profileViewModel +import ru.n08i40k.polytechnic.next.settings.settings open class AuthorizedRequest( - context: Context, + val appContainer: AppContainer, method: Int, url: String, listener: Response.Listener, errorListener: Response.ErrorListener?, - private val canBeUnauthorized: Boolean = false + private val canBeUnauthorized: Boolean = false, ) : RequestBase( - context, method, url, listener, - Response.ErrorListener { - if (!canBeUnauthorized && it is AuthFailureError) { - runBlocking { - context.settingsDataStore.updateData { currentSettings -> - currentSettings.toBuilder().setUserId("") - .setAccessToken("").build() - } - } - if (context.profileViewModel != null) - context.profileViewModel!!.onUnauthorized() - } + @Singleton + object : Response.ErrorListener { + override fun onErrorResponse(error: VolleyError?) { + val context = appContainer.context - errorListener?.onErrorResponse(it) + if (!canBeUnauthorized && error is AuthFailureError) { + runBlocking { + context.settings.updateData { currentSettings -> + currentSettings + .toBuilder() + .clear() + .build() + } + } + + // TODO: если не авторизован +// if (context.profileViewModel != null) +// context.profileViewModel!!.onUnauthorized() + } + + runBlocking { appContainer.profileRepository.signOut() } + + errorListener?.onErrorResponse(error) + + } }) { + override fun getHeaders(): MutableMap { 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) - context.profileViewModel!!.onUnauthorized() + // TODO: если не авторизован +// if (accessToken.isEmpty() && context.profileViewModel != null) +// context.profileViewModel!!.onUnauthorized() val headers = super.getHeaders() headers["Authorization"] = "Bearer $accessToken" return headers } + + val appContext get() = appContainer.context } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/CachedRequest.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/CachedRequest.kt index e17d45c..4f234de 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/CachedRequest.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/CachedRequest.kt @@ -7,31 +7,36 @@ import com.android.volley.toolbox.StringRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import ru.n08i40k.polytechnic.next.PolytechnicApplication -import ru.n08i40k.polytechnic.next.data.AppContainer -import ru.n08i40k.polytechnic.next.data.MyResult +import ru.n08i40k.polytechnic.next.Application +import ru.n08i40k.polytechnic.next.app.AppContainer +import ru.n08i40k.polytechnic.next.app.appContainer import ru.n08i40k.polytechnic.next.network.NetworkConnection import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetCacheStatus import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleUpdate import ru.n08i40k.polytechnic.next.network.tryFuture import ru.n08i40k.polytechnic.next.network.tryGet +import ru.n08i40k.polytechnic.next.utils.MyResult import java.util.logging.Logger import java.util.regex.Pattern open class CachedRequest( - context: Context, + appContainer: AppContainer, method: Int, private val url: String, private val listener: Response.Listener, errorListener: Response.ErrorListener?, -) : AuthorizedRequest(context, method, url, { - runBlocking(Dispatchers.IO) { - (context as PolytechnicApplication) - .container.networkCacheRepository.put(url, it) - } - listener.onResponse(it) -}, errorListener) { - private val appContainer: AppContainer = (context as PolytechnicApplication).container +) : AuthorizedRequest( + appContainer, + method, + url, + { + runBlocking(Dispatchers.IO) { + appContainer.networkCacheRepository.put(url, it) + } + listener.onResponse(it) + }, + errorListener +) { private suspend fun getXlsUrl(): MyResult = withContext(Dispatchers.IO) { val mainPageFuture = RequestFuture.newFuture() @@ -41,7 +46,7 @@ open class CachedRequest( mainPageFuture, mainPageFuture ) - NetworkConnection.getInstance(context).addToRequestQueue(request) + NetworkConnection.getInstance(appContext).addToRequestQueue(request) val response = tryGet(mainPageFuture) if (response is MyResult.Failure) @@ -49,7 +54,8 @@ open class CachedRequest( 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 = Pattern.compile(remoteConfig.getString("linkParserRegex"), Pattern.MULTILINE) @@ -67,10 +73,10 @@ open class CachedRequest( when (val xlsUrl = getXlsUrl()) { is MyResult.Failure -> xlsUrl is MyResult.Success -> { - tryFuture { + tryFuture(appContext) { it -> ScheduleUpdate( + appContext.appContainer, ScheduleUpdate.RequestDto(xlsUrl.data), - context, 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 repository = appContainer.networkCacheRepository + val cache = appContainer.networkCacheRepository - val cacheStatusResult = tryFuture { - ScheduleGetCacheStatus(context, it, it) + val cacheStatusResult = tryFuture(context) { + ScheduleGetCacheStatus(appContainer, it, it) } if (cacheStatusResult is MyResult.Success) { val cacheStatus = cacheStatusResult.data runBlocking { - repository.setUpdateDates( + cache.setUpdateDates( cacheStatus.lastCacheUpdate, cacheStatus.lastScheduleUpdate ) - repository.setHash(cacheStatus.cacheHash) + cache.setHash(cacheStatus.cacheHash) } if (cacheStatus.cacheUpdateRequired) { @@ -105,11 +112,11 @@ open class CachedRequest( when (updateResult) { is MyResult.Success -> { runBlocking { - repository.setUpdateDates( + cache.setUpdateDates( updateResult.data.lastCacheUpdate, 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!") } - val cachedResponse = runBlocking { repository.get(url) } + val cachedResponse = runBlocking { cache.get(url) } if (cachedResponse != null) { listener.onResponse(cachedResponse.data) return } - super.send() + super.send(context) } } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthChangePassword.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthChangePassword.kt index 73029fd..e7efe97 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthChangePassword.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthChangePassword.kt @@ -1,19 +1,19 @@ package ru.n08i40k.polytechnic.next.network.request.auth -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest class AuthChangePassword( + appContainer: AppContainer, private val data: RequestDto, - context: Context, listener: Response.Listener, errorListener: Response.ErrorListener? ) : AuthorizedRequest( - context, + appContainer, Method.POST, "v1/auth/change-password", { listener.onResponse(null) }, diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignIn.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignIn.kt index 3469e30..a298991 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignIn.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignIn.kt @@ -1,25 +1,45 @@ package ru.n08i40k.polytechnic.next.network.request.auth -import android.content.Context import com.android.volley.Response +import com.android.volley.VolleyError 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 +import ru.n08i40k.polytechnic.next.utils.EnumAsStringSerializer class AuthSignIn( private val data: RequestDto, - context: Context, listener: Response.Listener, errorListener: Response.ErrorListener? ) : RequestBase( - context, Method.POST, - "v2/auth/sign-in", + "v1/auth/sign-in", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) { + companion object { + private class ErrorCodeSerializer : EnumAsStringSerializer( + "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.networkResponse.data.decodeToString()) + } + } + @Serializable data class RequestDto(val username: String, val password: String) diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignInVK.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignInVK.kt new file mode 100644 index 0000000..ef0bcf9 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignInVK.kt @@ -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, + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignUp.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignUp.kt index edc3382..29191c9 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignUp.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignUp.kt @@ -1,32 +1,56 @@ package ru.n08i40k.polytechnic.next.network.request.auth -import android.content.Context import com.android.volley.Response +import com.android.volley.VolleyError 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 +import ru.n08i40k.polytechnic.next.utils.EnumAsStringSerializer class AuthSignUp( private val data: RequestDto, - context: Context, listener: Response.Listener, errorListener: Response.ErrorListener? ) : RequestBase( - context, Method.POST, - "v2/auth/sign-up", + "v1/auth/sign-up", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) { + companion object { + private class ErrorCodeSerializer : EnumAsStringSerializer( + "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.networkResponse.data.decodeToString()) + } + } + @Serializable data class RequestDto( val username: String, val password: String, val group: String, - val role: UserRole + val role: UserRole, + val version: String ) override fun getBody(): ByteArray { diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignUpVK.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignUpVK.kt new file mode 100644 index 0000000..e23f6a4 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/auth/AuthSignUpVK.kt @@ -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, + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/fcm/FcmSetToken.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/fcm/FcmSetToken.kt index 5b5bfac..eba6176 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/fcm/FcmSetToken.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/fcm/FcmSetToken.kt @@ -1,18 +1,26 @@ package ru.n08i40k.polytechnic.next.network.request.fcm -import android.content.Context import com.android.volley.Response +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest class FcmSetToken( - context: Context, + appContainer: AppContainer, token: String, listener: Response.Listener, errorListener: Response.ErrorListener?, ) : AuthorizedRequest( - context, Method.POST, - "v1/fcm/set-token/$token", + appContainer, + Method.PATCH, + "v1/fcm/set-token?token=$token", { listener.onResponse(Unit) }, errorListener, true -) \ No newline at end of file +) { + override fun getHeaders(): MutableMap { + val headers = super.getHeaders() + headers.remove("Content-Type") + + return headers + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/fcm/FcmUpdateCallback.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/fcm/FcmUpdateCallback.kt index 9d11699..a82dd02 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/fcm/FcmUpdateCallback.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/fcm/FcmUpdateCallback.kt @@ -1,16 +1,19 @@ package ru.n08i40k.polytechnic.next.network.request.fcm -import android.content.Context import com.android.volley.Response +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest +// TODO: вернуть +@Suppress("unused") class FcmUpdateCallback( - context: Context, + appContainer: AppContainer, version: String, listener: Response.Listener, errorListener: Response.ErrorListener?, ) : AuthorizedRequest( - context, Method.POST, + appContainer, + Method.POST, "v1/fcm/update-callback/$version", { listener.onResponse(Unit) }, errorListener, diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileChangeGroup.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileChangeGroup.kt index 851a49c..ba53cac 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileChangeGroup.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileChangeGroup.kt @@ -1,19 +1,19 @@ package ru.n08i40k.polytechnic.next.network.request.profile -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest class ProfileChangeGroup( + appContainer: AppContainer, private val data: RequestDto, - context: Context, listener: Response.Listener, errorListener: Response.ErrorListener? ) : AuthorizedRequest( - context, + appContainer, Method.POST, "v1/users/change-group", { listener.onResponse(null) }, diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileChangeUsername.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileChangeUsername.kt index 3cd81a0..5a545a4 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileChangeUsername.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileChangeUsername.kt @@ -1,19 +1,19 @@ package ru.n08i40k.polytechnic.next.network.request.profile -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest class ProfileChangeUsername( + appContainer: AppContainer, private val data: RequestDto, - context: Context, listener: Response.Listener, errorListener: Response.ErrorListener? ) : AuthorizedRequest( - context, + appContainer, Method.POST, "v1/users/change-username", { listener.onResponse(null) }, diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileMe.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileMe.kt index 03ce8d4..f5955bb 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileMe.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/profile/ProfileMe.kt @@ -1,19 +1,19 @@ package ru.n08i40k.polytechnic.next.network.request.profile -import android.content.Context import com.android.volley.Response import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.model.Profile import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest class ProfileMe( - context: Context, + appContainer: AppContainer, listener: Response.Listener, errorListener: Response.ErrorListener? ) : AuthorizedRequest( - context, + appContainer, Method.GET, - "v2/users/me", + "v1/users/me", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGet.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGet.kt index 44b51ee..fcccfb6 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGet.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGet.kt @@ -1,20 +1,20 @@ package ru.n08i40k.polytechnic.next.network.request.schedule -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.network.request.CachedRequest class ScheduleGet( - context: Context, + appContainer: AppContainer, listener: Response.Listener, errorListener: Response.ErrorListener? = null ) : CachedRequest( - context, + appContainer, Method.GET, - "v4/schedule/group", + "v1/schedule/group", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) { diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetCacheStatus.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetCacheStatus.kt index a15db8e..1b6edf8 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetCacheStatus.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetCacheStatus.kt @@ -1,19 +1,19 @@ package ru.n08i40k.polytechnic.next.network.request.schedule -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest class ScheduleGetCacheStatus( - context: Context, + appContainer: AppContainer, listener: Response.Listener, errorListener: Response.ErrorListener? = null ) : AuthorizedRequest( - context, + appContainer, Method.GET, - "v2/schedule/cache-status", + "v1/schedule/cache-status", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) { diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetGroupNames.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetGroupNames.kt index e4b28f7..0875c92 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetGroupNames.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetGroupNames.kt @@ -1,19 +1,16 @@ package ru.n08i40k.polytechnic.next.network.request.schedule -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import ru.n08i40k.polytechnic.next.network.RequestBase class ScheduleGetGroupNames( - context: Context, listener: Response.Listener, errorListener: Response.ErrorListener? = null ) : RequestBase( - context, Method.GET, - "v2/schedule/group-names", + "v1/schedule/group-names", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) { diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetTeacher.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetTeacher.kt index d9103d2..ffa84c1 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetTeacher.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetTeacher.kt @@ -1,21 +1,21 @@ package ru.n08i40k.polytechnic.next.network.request.schedule -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.network.request.CachedRequest class ScheduleGetTeacher( - context: Context, + appContainer: AppContainer, teacher: String, listener: Response.Listener, errorListener: Response.ErrorListener? = null ) : CachedRequest( - context, + appContainer, Method.GET, - "v3/schedule/teacher/$teacher", + "v1/schedule/teacher/$teacher", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) { diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetTeacherNames.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetTeacherNames.kt index 5622311..70bc41c 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetTeacherNames.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleGetTeacherNames.kt @@ -1,19 +1,16 @@ package ru.n08i40k.polytechnic.next.network.request.schedule -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import ru.n08i40k.polytechnic.next.network.RequestBase class ScheduleGetTeacherNames( - context: Context, listener: Response.Listener, errorListener: Response.ErrorListener? = null ) : RequestBase( - context, Method.GET, - "v2/schedule/teacher-names", + "v1/schedule/teacher-names", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) { diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleUpdate.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleUpdate.kt index 9fcefa3..b6d61c8 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleUpdate.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/schedule/ScheduleUpdate.kt @@ -1,21 +1,21 @@ package ru.n08i40k.polytechnic.next.network.request.schedule -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest class ScheduleUpdate( + appContainer: AppContainer, private val data: RequestDto, - context: Context, listener: Response.Listener, errorListener: Response.ErrorListener? = null ) : AuthorizedRequest( - context, + appContainer, Method.PATCH, - "v4/schedule/update-download-url", + "v1/schedule/update-download-url", { listener.onResponse(Json.decodeFromString(it)) }, errorListener ) { diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerClear.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerClear.kt index 26a8d9f..867dbe3 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerClear.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerClear.kt @@ -1,17 +1,19 @@ package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer -import android.content.Context import com.android.volley.Response import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest +// TODO: вернуть +@Suppress("unused") class ScheduleReplacerClear( - context: Context, + appContainer: AppContainer, listener: Response.Listener, errorListener: Response.ErrorListener? ) : AuthorizedRequest( - context, + appContainer, Method.POST, "v1/schedule-replacer/clear", { listener.onResponse(Json.decodeFromString(it)) }, diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerGet.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerGet.kt index 1180068..fc22018 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerGet.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerGet.kt @@ -1,17 +1,19 @@ package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer -import android.content.Context import com.android.volley.Response import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.model.ScheduleReplacer import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest +// TODO: вернуть +@Suppress("unused") class ScheduleReplacerGet( - context: Context, + appContainer: AppContainer, listener: Response.Listener>, errorListener: Response.ErrorListener? ) : AuthorizedRequest( - context, + appContainer, Method.GET, "v1/schedule-replacer/get", { listener.onResponse(Json.decodeFromString(it)) }, diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerSet.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerSet.kt index e3216a3..b5cd140 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerSet.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/scheduleReplacer/ScheduleReplacerSet.kt @@ -1,18 +1,20 @@ package ru.n08i40k.polytechnic.next.network.request.scheduleReplacer -import android.content.Context 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( - context: Context, + appContainer: AppContainer, private val fileName: String, private val fileData: ByteArray, private val fileType: String, private val listener: Response.Listener, errorListener: Response.ErrorListener? ) : AuthorizedMultipartRequest( - context, + appContainer, Method.POST, "v1/schedule-replacer/set", { listener.onResponse(null) }, diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/vkid/VKIDOAuth.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/vkid/VKIDOAuth.kt new file mode 100644 index 0000000..7568a09 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/request/vkid/VKIDOAuth.kt @@ -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, + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/NetworkCacheRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/NetworkCacheRepository.kt similarity index 89% rename from app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/NetworkCacheRepository.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/NetworkCacheRepository.kt index 1762a40..66a6ff1 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/NetworkCacheRepository.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/NetworkCacheRepository.kt @@ -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.UpdateDates diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/impl/LocalNetworkCacheRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/impl/LocalNetworkCacheRepository.kt similarity index 81% rename from app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/impl/LocalNetworkCacheRepository.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/impl/LocalNetworkCacheRepository.kt index 586282b..05b79ef 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/impl/LocalNetworkCacheRepository.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/impl/LocalNetworkCacheRepository.kt @@ -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.flow.first import kotlinx.coroutines.flow.map @@ -8,23 +7,26 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import ru.n08i40k.polytechnic.next.CachedResponse import ru.n08i40k.polytechnic.next.UpdateDates -import ru.n08i40k.polytechnic.next.data.cache.NetworkCacheRepository -import ru.n08i40k.polytechnic.next.settings.settingsDataStore +import ru.n08i40k.polytechnic.next.app.AppContainer +import ru.n08i40k.polytechnic.next.repository.cache.NetworkCacheRepository +import ru.n08i40k.polytechnic.next.settings.settings import javax.inject.Inject class LocalNetworkCacheRepository -@Inject constructor(private val applicationContext: Context) : NetworkCacheRepository { +@Inject constructor(private val appContainer: AppContainer) : NetworkCacheRepository { private val cacheMap: MutableMap = mutableMapOf() private var updateDates: UpdateDates = UpdateDates.newBuilder().build() private var hash: String? = null + private val context get() = appContainer.context + init { cacheMap.clear() runBlocking { cacheMap.putAll( - applicationContext - .settingsDataStore + context + .settings .data .map { settings -> settings.cacheStorageMap }.first() ) @@ -32,7 +34,7 @@ class LocalNetworkCacheRepository } override suspend fun get(url: String): CachedResponse? { - // Если кешированого ответа нет, то возвращаем null + // Если кешированного ответа нет, то возвращаем null // Если хеши не совпадают и локальный хеш присутствует, то возвращаем null val response = cacheMap[url] ?: return null @@ -92,7 +94,7 @@ class LocalNetworkCacheRepository .setSchedule(schedule).build() withContext(Dispatchers.IO) { - applicationContext.settingsDataStore.updateData { + context.settings.updateData { it .toBuilder() .setUpdateDates(updateDates) @@ -104,7 +106,7 @@ class LocalNetworkCacheRepository private suspend fun save() { withContext(Dispatchers.IO) { - applicationContext.settingsDataStore.updateData { + context.settings.updateData { it .toBuilder() .putAllCacheStorage(cacheMap) diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/impl/FakeNetworkCacheRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/impl/MockNetworkCacheRepository.kt similarity index 75% rename from app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/impl/FakeNetworkCacheRepository.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/impl/MockNetworkCacheRepository.kt index ef8bbe7..188dd53 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/cache/impl/FakeNetworkCacheRepository.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/cache/impl/MockNetworkCacheRepository.kt @@ -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.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? { return null } diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/ProfileRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/ProfileRepository.kt new file mode 100644 index 0000000..63e336c --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/ProfileRepository.kt @@ -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 + + suspend fun setFCMToken(token: String): MyResult + + suspend fun signOut() +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/impl/MockProfileRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/impl/MockProfileRepository.kt new file mode 100644 index 0000000..d70734a --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/impl/MockProfileRepository.kt @@ -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 = + withContext(Dispatchers.IO) { + delay(1500) + + if (++getCounter % 3 == 0) + MyResult.Failure(Exception()) + else + MyResult.Success(profile) + } + + override suspend fun setFCMToken(token: String): MyResult = + MyResult.Success(Unit) + + override suspend fun signOut() { + + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/impl/RemoteProfileRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/impl/RemoteProfileRepository.kt new file mode 100644 index 0000000..3047465 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/profile/impl/RemoteProfileRepository.kt @@ -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 { + return withContext(Dispatchers.IO) { + tryFuture(container.context) { + ProfileMe( + container, + it, + it + ) + } + } + } + + override suspend fun setFCMToken(token: String): MyResult = + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/ScheduleRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/ScheduleRepository.kt similarity index 66% rename from app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/ScheduleRepository.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/ScheduleRepository.kt index 4d75c5b..c5b11eb 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/ScheduleRepository.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/ScheduleRepository.kt @@ -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.utils.MyResult interface ScheduleRepository { suspend fun getGroup(): MyResult diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/FakeScheduleRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/impl/MockScheduleRepository.kt similarity index 93% rename from app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/FakeScheduleRepository.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/impl/MockScheduleRepository.kt index 73f75fc..f6447e0 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/FakeScheduleRepository.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/impl/MockScheduleRepository.kt @@ -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.delay -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone 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.GroupOrTeacher import ru.n08i40k.polytechnic.next.model.Lesson import ru.n08i40k.polytechnic.next.model.LessonTime import ru.n08i40k.polytechnic.next.model.LessonType 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 private fun genLocalDateTime(hour: Int, minute: Int): Instant { @@ -36,8 +35,7 @@ private fun genBreak(start: Instant, end: Instant): Lesson { ) } -class FakeScheduleRepository : ScheduleRepository { - @Suppress("SpellCheckingInspection") +class MockScheduleRepository : ScheduleRepository { companion object { val exampleGroup = GroupOrTeacher( name = "ИС-214/23", days = arrayListOf( @@ -144,7 +142,8 @@ class FakeScheduleRepository : ScheduleRepository { ), group = null ), - ) + ), + street = "Железнодорожная 13", ) ) ) @@ -254,34 +253,28 @@ class FakeScheduleRepository : ScheduleRepository { ), group = "ИС-214/23" ), - ) + ), + null ) ) ) } - private val group = MutableStateFlow(exampleGroup) - private val teacher = MutableStateFlow(exampleTeacher) - private var updateCounter: Int = 0 override suspend fun getGroup(): MyResult { return withContext(Dispatchers.IO) { delay(1500) - if (updateCounter++ % 3 == 0) MyResult.Failure( - IllegalStateException() - ) - else MyResult.Success(group.value!!) + if (updateCounter++ % 3 == 0) MyResult.Failure() + else MyResult.Success(exampleGroup) } } override suspend fun getTeacher(name: String): MyResult { return withContext(Dispatchers.IO) { delay(1500) - if (updateCounter++ % 3 == 0) MyResult.Failure( - IllegalStateException() - ) - else MyResult.Success(teacher.value!!) + if (updateCounter++ % 3 == 0) MyResult.Failure() + else MyResult.Success(exampleTeacher) } } } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/RemoteScheduleRepository.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/impl/RemoteScheduleRepository.kt similarity index 68% rename from app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/RemoteScheduleRepository.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/impl/RemoteScheduleRepository.kt index 20adb34..166b2c8 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/RemoteScheduleRepository.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/repository/schedule/impl/RemoteScheduleRepository.kt @@ -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.withContext -import ru.n08i40k.polytechnic.next.data.MyResult -import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.model.GroupOrTeacher import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGet import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetTeacher 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 = withContext(Dispatchers.IO) { - val response = tryFuture { + val response = tryFuture(context) { ScheduleGet( - context, + container, it, it ) } when (response) { - is MyResult.Failure -> response is MyResult.Success -> MyResult.Success(response.data.group) + is MyResult.Failure -> response } } override suspend fun getTeacher(name: String): MyResult = withContext(Dispatchers.IO) { - val response = tryFuture { + val response = tryFuture(context) { ScheduleGetTeacher( - context, + container, name, it, it diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/service/CurrentLessonViewService.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/service/CurrentLessonViewService.kt deleted file mode 100644 index 9333eb3..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/service/CurrentLessonViewService.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/service/FCMService.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/service/FCMService.kt new file mode 100644 index 0000000..63aea01 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/service/FCMService.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/service/MyFirebaseMessagingService.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/service/MyFirebaseMessagingService.kt deleted file mode 100644 index 36d9f26..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/service/MyFirebaseMessagingService.kt +++ /dev/null @@ -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() - .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) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/settings/SettingsSerializer.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/settings/SettingsSerializer.kt index 82a405b..54a9ffa 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/settings/SettingsSerializer.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/settings/SettingsSerializer.kt @@ -13,18 +13,17 @@ import java.io.OutputStream object SettingsSerializer : Serializer { override val defaultValue: Settings = Settings.getDefaultInstance() - override suspend fun readFrom(input: InputStream): Settings { + override suspend fun readFrom(input: InputStream): Settings = try { - return Settings.parseFrom(input) + Settings.parseFrom(input) } catch (exception: InvalidProtocolBufferException) { throw CorruptionException("Cannot read proto.", exception) } - } override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output) } -val Context.settingsDataStore: DataStore by dataStore( +val Context.settings: DataStore by dataStore( fileName = "settings.pb", serializer = SettingsSerializer ) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/MainActivity.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/MainActivity.kt deleted file mode 100644 index 18f0aeb..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/MainActivity.kt +++ /dev/null @@ -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() - .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() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/PolytechnicApp.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/PolytechnicApp.kt index cd48bbc..c5e05bb 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/PolytechnicApp.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/PolytechnicApp.kt @@ -1,42 +1,276 @@ 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.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.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.composable import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking -import ru.n08i40k.polytechnic.next.settings.settingsDataStore -import ru.n08i40k.polytechnic.next.ui.auth.AuthScreen -import ru.n08i40k.polytechnic.next.ui.main.MainScreen -import ru.n08i40k.polytechnic.next.ui.theme.AppTheme +import ru.n08i40k.polytechnic.next.Application +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.settings.settings +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 { + 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 fun PolytechnicApp() { - AppTheme(darkTheme = true, content = { - val navController = rememberNavController() - val context = LocalContext.current + if (!checkUpdate()) + return - val accessToken = runBlocking { - context.settingsDataStore.data.map { it.accessToken }.first() + val navController = rememberNavController() + val context = LocalContext.current + + remember { + 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( + navController, + startDestination = if (token.isEmpty()) AppRoute.AUTH.route else AppRoute.MAIN.route + ) { + composable(AppRoute.AUTH.route) { + AuthScreen(navController) } - NavHost( - navController = navController, - startDestination = if (accessToken.isEmpty()) "auth" else "main" - ) { - composable(route = "auth") { - AuthScreen(navController) - } - - composable(route = "main") { - MainScreen(navController) - } + composable(AppRoute.MAIN.route) { + MainScreen(navController) } - }) + } } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/AuthScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/AuthScreen.kt deleted file mode 100644 index 727977f..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/AuthScreen.kt +++ /dev/null @@ -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 - ) - } - }) -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/SignInForm.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/SignInForm.kt deleted file mode 100644 index d0be085..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/SignInForm.kt +++ /dev/null @@ -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 - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/SignUpForm.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/SignUpForm.kt deleted file mode 100644 index f1c84b1..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/SignUpForm.kt +++ /dev/null @@ -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(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 - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/TrySignIn.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/TrySignIn.kt deleted file mode 100644 index 2c7d3e1..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/TrySignIn.kt +++ /dev/null @@ -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() -} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/TrySignUp.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/TrySignUp.kt deleted file mode 100644 index 8d60614..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/TrySignUp.kt +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/helper/SnackbarBox.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/helper/SnackbarBox.kt new file mode 100644 index 0000000..19c8696 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/helper/SnackbarBox.kt @@ -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) } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/helper/data/InputValue.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/helper/data/InputValue.kt new file mode 100644 index 0000000..7c299aa --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/helper/data/InputValue.kt @@ -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( + 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 rememberInputValue( + defaultValue: T, + checkNow: Boolean = false, + errorCheck: (T) -> Boolean = { false } +): MutableState> { + return remember { mutableStateOf(InputValue(defaultValue, errorCheck, checkNow)) } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/icons/appicons/filled/VK.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/icons/appicons/filled/VK.kt new file mode 100644 index 0000000..cb13033 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/icons/appicons/filled/VK.kt @@ -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 = "") + } +} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/Constants.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/Constants.kt deleted file mode 100644 index 8e06b81..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/Constants.kt +++ /dev/null @@ -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 - ) - ) -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/MainScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/MainScreen.kt deleted file mode 100644 index 4db7d5c..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/MainScreen.kt +++ /dev/null @@ -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(LocalContext.current as ComponentActivity) - - // teacher view model - val teacherScheduleViewModel = - hiltViewModel(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 - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangePasswordDialog.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangePasswordDialog.kt deleted file mode 100644 index b190a11..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangePasswordDialog.kt +++ /dev/null @@ -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)) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileCard.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileCard.kt deleted file mode 100644 index dc4b078..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileCard.kt +++ /dev/null @@ -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(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 } - } - } - } - } - } -} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/replacer/ReplacerScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/replacer/ReplacerScreen.kt deleted file mode 100644 index ddd021d..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/replacer/ReplacerScreen.kt +++ /dev/null @@ -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(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 = FakeScheduleReplacerRepository.exampleReplacers) { - Surface { - LazyColumn( - contentPadding = PaddingValues(0.dp, 5.dp), - modifier = Modifier - .fillMaxWidth() - .height(500.dp) - ) { - items(replacers) { - ReplacerElement(it) - } - } - } -} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/LessonView.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/LessonView.kt deleted file mode 100644 index 203ad9f..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/LessonView.kt +++ /dev/null @@ -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 { - 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 = 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 = - 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 - ) -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/GroupScheduleScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/GroupScheduleScreen.kt deleted file mode 100644 index 52287a4..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/GroupScheduleScreen.kt +++ /dev/null @@ -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) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/main/TeacherMainScheduleScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/main/TeacherMainScheduleScreen.kt deleted file mode 100644 index 1bed1ff..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/main/TeacherMainScheduleScreen.kt +++ /dev/null @@ -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 - ) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/SearchBox.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/SearchBox.kt deleted file mode 100644 index 84814d2..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/SearchBox.kt +++ /dev/null @@ -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, -) { - 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 - } - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/TeacherSearchBox.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/TeacherSearchBox.kt deleted file mode 100644 index 5638c9c..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/TeacherSearchBox.kt +++ /dev/null @@ -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 { - val teacherNames = remember { arrayListOf() } - - 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, - ) -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/TeacherUserScheduleScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/TeacherUserScheduleScreen.kt deleted file mode 100644 index dda37b2..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/teacher/user/TeacherUserScheduleScreen.kt +++ /dev/null @@ -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 - ) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/GroupScheduleViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/GroupViewModel.kt similarity index 54% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/GroupScheduleViewModel.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/GroupViewModel.kt index c81575b..e3d9c4e 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/GroupScheduleViewModel.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/GroupViewModel.kt @@ -10,77 +10,76 @@ 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.data.AppContainer -import ru.n08i40k.polytechnic.next.data.MyResult +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.model.GroupOrTeacher +import ru.n08i40k.polytechnic.next.utils.MyResult import java.util.Date import javax.inject.Inject -sealed interface GroupScheduleUiState { +sealed interface GroupUiState { val isLoading: Boolean data class NoData( override val isLoading: Boolean - ) : GroupScheduleUiState + ) : GroupUiState data class HasData( val group: GroupOrTeacher, val updateDates: UpdateDates, val lastUpdateAt: Long, override val isLoading: Boolean - ) : GroupScheduleUiState + ) : GroupUiState } -private data class GroupScheduleViewModelState( +private data class GroupViewModelState( val group: GroupOrTeacher? = null, val updateDates: UpdateDates? = null, val lastUpdateAt: Long = 0, val isLoading: Boolean = false ) { - fun toUiState(): GroupScheduleUiState = if (group == null) { - GroupScheduleUiState.NoData(isLoading) - } else { - GroupScheduleUiState.HasData(group, updateDates!!, lastUpdateAt, isLoading) - } + fun toUiState(): GroupUiState = + if (group == null) + GroupUiState.NoData(isLoading) + else + GroupUiState.HasData(group, updateDates!!, lastUpdateAt, isLoading) } @HiltViewModel -class GroupScheduleViewModel @Inject constructor( +class GroupViewModel @Inject constructor( appContainer: AppContainer ) : ViewModel() { private val scheduleRepository = appContainer.scheduleRepository private val networkCacheRepository = appContainer.networkCacheRepository - private val viewModelState = MutableStateFlow(GroupScheduleViewModelState(isLoading = true)) - val uiState = viewModelState - .map(GroupScheduleViewModelState::toUiState) - .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) + private val state = MutableStateFlow(GroupViewModelState(isLoading = true)) + + val uiState = state + .map(GroupViewModelState::toUiState) + .stateIn(viewModelScope, SharingStarted.Companion.Eagerly, state.value.toUiState()) init { refresh() } fun refresh() { - viewModelState.update { it.copy(isLoading = true) } + state.update { it.copy(isLoading = true) } viewModelScope.launch { val result = scheduleRepository.getGroup() - viewModelState.update { + state.update { when (result) { - is MyResult.Success -> { - val updateDates = networkCacheRepository.getUpdateDates() - - it.copy( - group = result.data, - updateDates = updateDates, - lastUpdateAt = Date().time, - isLoading = false - ) - } + is MyResult.Success -> it.copy( + group = result.data, + updateDates = networkCacheRepository.getUpdateDates(), + lastUpdateAt = Date().time, + isLoading = false + ) is MyResult.Failure -> it.copy( group = null, + updateDates = null, + lastUpdateAt = 0, isLoading = false ) } diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ProfileViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ProfileViewModel.kt index 1533a41..e8c36a7 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ProfileViewModel.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ProfileViewModel.kt @@ -1,32 +1,30 @@ 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.ViewModelProvider 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.MyResult -import ru.n08i40k.polytechnic.next.data.users.ProfileRepository +import ru.n08i40k.polytechnic.next.app.AppContainer 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 { val isLoading: Boolean - data class NoProfile( + data class NoData( override val isLoading: Boolean ) : ProfileUiState - data class HasProfile( - val profile: Profile, - override val isLoading: Boolean + data class HasData( + override val isLoading: Boolean, + val profile: Profile ) : ProfileUiState } @@ -34,59 +32,53 @@ private data class ProfileViewModelState( val profile: Profile? = null, val isLoading: Boolean = false ) { - fun toUiState(): ProfileUiState = if (profile == null) { - ProfileUiState.NoProfile(isLoading) - } else { - ProfileUiState.HasProfile(profile, isLoading) + fun toUiState(): ProfileUiState = when (profile) { + null -> ProfileUiState.NoData(isLoading) + else -> ProfileUiState.HasData(isLoading, profile) } } - -class ProfileViewModel( - private val profileRepository: ProfileRepository, - val onUnauthorized: () -> Unit +@HiltViewModel +class ProfileViewModel @Inject constructor( + appContainer: AppContainer ) : 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) - .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) + .stateIn(viewModelScope, SharingStarted.Eagerly, state.value.toUiState()) init { - refreshProfile() + refresh() } - fun refreshProfile(callback: () -> Unit = {}) { - viewModelState.update { it.copy(isLoading = true) } + // TODO: сделать хук на unauthorized и сделать так что бы waiter удалялся, если сход контекст + + fun refresh(): SingleHook { + val singleHook = SingleHook() + + state.update { it.copy(isLoading = true) } viewModelScope.launch { - val result = profileRepository.getProfile() - - viewModelState.update { - when (result) { - is MyResult.Success -> it.copy(profile = result.data, isLoading = false) - is MyResult.Failure -> it.copy(profile = null, isLoading = false) + repository.getProfile().let { result -> + state.update { + when (result) { + is MyResult.Failure -> it.copy(null, false) + is MyResult.Success -> it.copy(result.data, false) + } } - } - callback() + singleHook.resolve( + if (result is MyResult.Success) + result.data + else + null + ) + } } - } - companion object { - fun provideFactory( - profileRepository: ProfileRepository, - onUnauthorized: () -> Unit - ): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") return ProfileViewModel( - profileRepository, - onUnauthorized - ) as T - } - } + return singleHook } -} - -var Context.profileViewModel: ProfileViewModel? by mutableStateOf(null) \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/RemoteConfigViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/RemoteConfigViewModel.kt index 22d3eef..4268440 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/RemoteConfigViewModel.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/RemoteConfigViewModel.kt @@ -1,19 +1,18 @@ package ru.n08i40k.polytechnic.next.ui.model -import android.content.Context import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.google.firebase.remoteconfig.ConfigUpdate import com.google.firebase.remoteconfig.ConfigUpdateListener -import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.google.firebase.remoteconfig.FirebaseRemoteConfigException +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn 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 javax.inject.Inject data class RemoteConfigUiState( val minVersion: String, @@ -24,12 +23,13 @@ data class RemoteConfigUiState( val linkUpdateDelay: Long, ) - -class RemoteConfigViewModel( - private val appContext: Context, - private val remoteConfig: FirebaseRemoteConfig, +@HiltViewModel +class RemoteConfigViewModel @Inject constructor( + appContainer: AppContainer ) : ViewModel() { - private val viewModelState = MutableStateFlow( + private val remoteConfig = appContainer.remoteConfig + + private val state = MutableStateFlow( RemoteConfigUiState( minVersion = remoteConfig.getString("minVersion"), currVersion = remoteConfig.getString("currVersion"), @@ -40,17 +40,14 @@ class RemoteConfigViewModel( ) ) - val uiState = viewModelState - .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value) + val uiState = state + .stateIn(viewModelScope, SharingStarted.Eagerly, state.value) init { - (appContext as MainActivity) - .scheduleLinkUpdate(viewModelState.value.linkUpdateDelay) - remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { override fun onUpdate(configUpdate: ConfigUpdate) { remoteConfig.activate().addOnCompleteListener { - viewModelState.update { + state.update { it.copy( minVersion = remoteConfig.getString("minVersion"), currVersion = remoteConfig.getString("currVersion"), @@ -60,8 +57,6 @@ class RemoteConfigViewModel( 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 create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") return RemoteConfigViewModel( - appContext, - remoteConfig, - ) as T - } - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ScheduleReplacerViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ScheduleReplacerViewModel.kt deleted file mode 100644 index 6ad8245..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ScheduleReplacerViewModel.kt +++ /dev/null @@ -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, - ) : ScheduleReplacerUiState -} - -private data class ScheduleReplacerViewModelState( - val isLoading: Boolean = false, - val replacers: List? = 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 - ) - } - } - } -} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/TeacherScheduleViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/SearchViewModel.kt similarity index 51% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/TeacherScheduleViewModel.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/SearchViewModel.kt index 1bddf3a..0aab3ac 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/TeacherScheduleViewModel.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/SearchViewModel.kt @@ -10,89 +10,95 @@ 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.data.AppContainer -import ru.n08i40k.polytechnic.next.data.MyResult +import ru.n08i40k.polytechnic.next.app.AppContainer import ru.n08i40k.polytechnic.next.model.GroupOrTeacher +import ru.n08i40k.polytechnic.next.utils.MyResult import java.util.Date import javax.inject.Inject -sealed interface TeacherScheduleUiState { +sealed interface SearchUiState { val isLoading: Boolean data class NoData( override val isLoading: Boolean - ) : TeacherScheduleUiState + ) : SearchUiState data class HasData( val teacher: GroupOrTeacher, val updateDates: UpdateDates, val lastUpdateAt: Long, override val isLoading: Boolean - ) : TeacherScheduleUiState + ) : SearchUiState } -private data class TeacherScheduleViewModelState( +private data class SearchViewModelState( val teacher: GroupOrTeacher? = null, val updateDates: UpdateDates? = null, val lastUpdateAt: Long = 0, val isLoading: Boolean = false ) { - fun toUiState(): TeacherScheduleUiState = if (teacher == null) { - TeacherScheduleUiState.NoData(isLoading) - } else { - TeacherScheduleUiState.HasData(teacher, updateDates!!, lastUpdateAt, isLoading) - } + fun toUiState(): SearchUiState = + if (teacher == null) SearchUiState.NoData(isLoading) + else SearchUiState.HasData(teacher, updateDates!!, lastUpdateAt, isLoading) } @HiltViewModel -class TeacherScheduleViewModel @Inject constructor( +class SearchViewModel @Inject constructor( appContainer: AppContainer ) : ViewModel() { private val scheduleRepository = appContainer.scheduleRepository private val networkCacheRepository = appContainer.networkCacheRepository - private val viewModelState = MutableStateFlow(TeacherScheduleViewModelState(isLoading = true)) - val uiState = viewModelState - .map(TeacherScheduleViewModelState::toUiState) - .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) + private val state = MutableStateFlow(SearchViewModelState(isLoading = true)) + + val uiState = state + .map(SearchViewModelState::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, state.value.toUiState()) + + private var teacherName: String? = null init { - fetch(null) + refresh() } - fun fetch(name: String?) { - if (name == null) { - viewModelState.update { + fun set(name: String?) { + teacherName = name + refresh() + } + + fun refresh() { + state.update { it.copy(isLoading = true) } + + if (teacherName == null) { + state.update { it.copy( teacher = null, + updateDates = null, + lastUpdateAt = 0, isLoading = false ) } return } - viewModelState.update { it.copy(isLoading = true) } - viewModelScope.launch { - val result = scheduleRepository.getTeacher(name) - - viewModelState.update { - when (result) { - is MyResult.Success -> { - val updateDates = networkCacheRepository.getUpdateDates() - - it.copy( + scheduleRepository.getTeacher(teacherName!!).let { result -> + state.update { + when (result) { + is MyResult.Success -> it.copy( teacher = result.data, - updateDates = updateDates, + updateDates = networkCacheRepository.getUpdateDates(), lastUpdateAt = Date().time, isLoading = false ) - } - is MyResult.Failure -> it.copy( - teacher = null, - isLoading = false - ) + is MyResult.Failure -> it.copy( + teacher = null, + updateDates = null, + lastUpdateAt = 0, + isLoading = false + ) + } } } } diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/TeacherViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/TeacherViewModel.kt new file mode 100644 index 0000000..664cfe6 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/TeacherViewModel.kt @@ -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 + ) + } + } + } + } + } +} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/BottomNavBar.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/BottomNavBar.kt new file mode 100644 index 0000000..fca6b31 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/BottomNavBar.kt @@ -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) { + 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)) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/NavHostContainer.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/NavHostContainer.kt new file mode 100644 index 0000000..52a7d8f --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/NavHostContainer.kt @@ -0,0 +1,64 @@ +package ru.n08i40k.polytechnic.next.ui.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +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.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable + +@Composable +fun NavHostContainer( + navHostController: NavHostController, + padding: PaddingValues, + startDestination: String, + routes: Map Unit>, + enterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition)? = null, + exitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition)? = null, + sizeTransform: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> SizeTransform?)? = null, +) { + NavHost( + navController = navHostController, + modifier = Modifier.padding(padding), + startDestination = startDestination, + enterTransition = enterTransition ?: { + slideIn( + animationSpec = tween( + 400, + delayMillis = 250, + easing = LinearOutSlowInEasing + ) + ) { fullSize -> IntOffset(0, fullSize.height / 16) } + fadeIn( + animationSpec = tween( + 400, + delayMillis = 250, + easing = LinearOutSlowInEasing + ) + ) + }, + exitTransition = exitTransition ?: { + fadeOut( + animationSpec = tween( + 250, + easing = FastOutSlowInEasing + ) + ) + }, + sizeTransform = sizeTransform + ) { + routes.forEach { route -> composable(route.key) { route.value() } } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/TopAppBar.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/TopAppBar.kt new file mode 100644 index 0000000..978c854 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/navigation/TopAppBar.kt @@ -0,0 +1,57 @@ +package ru.n08i40k.polytechnic.next.ui.navigation + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +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.Text +import androidx.compose.material3.TopAppBar +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import ru.n08i40k.polytechnic.next.R + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopAppBar(badge: Boolean, items: List<@Composable ColumnScope.() -> Unit>) { + var dropdownExpanded by remember { mutableStateOf(false) } + + TopAppBar( + title = { + Text( + stringResource(R.string.app_name), + Modifier.fillMaxWidth(), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + }, + actions = { + IconButton({ dropdownExpanded = true }) { + BadgedBox({ if (badge) Badge() }) { + Icon(Icons.Filled.Menu, stringResource(R.string.cd_top_app_bar)) + } + } + + DropdownMenu(dropdownExpanded, { dropdownExpanded = false }) { + Column(Modifier.wrapContentWidth()) { + items.forEach { it() } + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/MainScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/MainScreen.kt new file mode 100644 index 0000000..108f8a4 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/MainScreen.kt @@ -0,0 +1,267 @@ +package ru.n08i40k.polytechnic.next.ui.screen + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +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.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import ru.n08i40k.polytechnic.next.Application +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.model.UserRole +import ru.n08i40k.polytechnic.next.settings.settings +import ru.n08i40k.polytechnic.next.ui.AppRoute +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.model.GroupViewModel +import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState +import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel +import ru.n08i40k.polytechnic.next.ui.model.RemoteConfigUiState +import ru.n08i40k.polytechnic.next.ui.model.RemoteConfigViewModel +import ru.n08i40k.polytechnic.next.ui.model.SearchViewModel +import ru.n08i40k.polytechnic.next.ui.model.TeacherViewModel +import ru.n08i40k.polytechnic.next.ui.navigation.BottomNavBar +import ru.n08i40k.polytechnic.next.ui.navigation.BottomNavItem +import ru.n08i40k.polytechnic.next.ui.navigation.NavHostContainer +import ru.n08i40k.polytechnic.next.ui.navigation.TopAppBar +import ru.n08i40k.polytechnic.next.ui.screen.profile.ProfileScreen +import ru.n08i40k.polytechnic.next.ui.screen.replacer.ReplacerScreen +import ru.n08i40k.polytechnic.next.ui.screen.schedule.GroupScheduleScreen +import ru.n08i40k.polytechnic.next.ui.screen.schedule.TeacherScheduleScreen +import ru.n08i40k.polytechnic.next.ui.screen.schedule.TeacherSearchScreen +import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent +import ru.n08i40k.polytechnic.next.utils.openLink + +private data class MainBottomNavItem( + val bottomNavItem: BottomNavItem, + val requiredRole: UserRole? +) + +private enum class MainScreenRoute(val route: String) { + PROFILE("profile"), + REPLACER("replacer"), + TEACHER_SCHEDULE("teacher-schedule"), + GROUP_SCHEDULE("group-schedule"), + TEACHER_SEARCH("teacher-search") +} + +private val mainNavBarItems = listOf( + MainBottomNavItem( + BottomNavItem( + R.string.profile, + Icons.Filled.AccountCircle, + MainScreenRoute.PROFILE.route + ), + null + ), + MainBottomNavItem( + BottomNavItem( + R.string.replacer, + Icons.Filled.Create, + MainScreenRoute.REPLACER.route + ), + UserRole.ADMIN + ), + MainBottomNavItem( + BottomNavItem( + R.string.teacher_schedule, + Icons.Filled.Person, + MainScreenRoute.TEACHER_SCHEDULE.route + ), + UserRole.TEACHER + ), + MainBottomNavItem( + BottomNavItem( + R.string.group_schedule, + Icons.Filled.DateRange, + MainScreenRoute.GROUP_SCHEDULE.route + ), + null + ), + MainBottomNavItem( + BottomNavItem( + R.string.teachers_schedule, + Icons.Filled.Person, + MainScreenRoute.TEACHER_SEARCH.route + ), + UserRole.STUDENT + ), +) + +@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 = { context.openLink(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) + } + } +} + +private fun topBarItems( + context: Context, + remoteConfigUiState: RemoteConfigUiState +): Pair Unit>> { + val packageVersion = (context.applicationContext as Application).version + val updateAvailable = remoteConfigUiState.currVersion != packageVersion + + return Pair Unit>>( + updateAvailable, + listOf( + { + 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 +fun MainScreen(navController: NavController) { + val context = LocalContext.current + + LaunchedEffect(context) { + runBlocking { + val accessToken = context.settings.data.map { it.accessToken }.first() + + if (accessToken.isEmpty()) { + navController.navigate(AppRoute.AUTH.route) { + popUpTo(AppRoute.AUTH.route) { inclusive = true } + } + } + } + } + + val viewModelStoreOwner = LocalActivity.current as ComponentActivity + + val profileViewModel = hiltViewModel(viewModelStoreOwner) + val profileUiState by profileViewModel.uiState.collectAsStateWithLifecycle() + + LoadingContent( + empty = false, + emptyContent = {}, + loading = profileUiState.isLoading, + ) { + val profile = + if (profileUiState is ProfileUiState.HasData) + (profileUiState as ProfileUiState.HasData).profile + else + null + + val role = profile?.role ?: UserRole.STUDENT + + val items = + mainNavBarItems.filter { + it.requiredRole == null + || (role == UserRole.ADMIN + || it.requiredRole == role) + } + + val groupViewModel = hiltViewModel(viewModelStoreOwner) + + val remoteConfigViewModel = hiltViewModel(viewModelStoreOwner) + val remoteConfigUiState by remoteConfigViewModel.uiState.collectAsStateWithLifecycle() + + val teacherViewModel = + if (role === UserRole.STUDENT) + null + else + hiltViewModel(viewModelStoreOwner) + + val searchViewModel = + if (role === UserRole.TEACHER) + null + else + hiltViewModel(viewModelStoreOwner) + + val routes = mapOf Unit>( + MainScreenRoute.PROFILE.route to { ProfileScreen(profileViewModel) }, + MainScreenRoute.REPLACER.route to { ReplacerScreen() }, + MainScreenRoute.TEACHER_SCHEDULE.route to { TeacherScheduleScreen(teacherViewModel!!) }, + MainScreenRoute.GROUP_SCHEDULE.route to { GroupScheduleScreen(groupViewModel) }, + MainScreenRoute.TEACHER_SEARCH.route to { TeacherSearchScreen(searchViewModel!!) }, + ) + + val topAppBar = topBarItems(context, remoteConfigUiState) + + val navHostController = rememberNavController() + Scaffold( + topBar = { TopAppBar(topAppBar.first, topAppBar.second) }, + bottomBar = { BottomNavBar(navHostController, items.map { it.bottomNavItem }) } + ) { paddingValues -> + NavHostContainer( + navHostController, + paddingValues, + if (role == UserRole.TEACHER) + MainScreenRoute.TEACHER_SCHEDULE.route + else + MainScreenRoute.GROUP_SCHEDULE.route, + routes + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/AuthScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/AuthScreen.kt new file mode 100644 index 0000000..9440c8a --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/AuthScreen.kt @@ -0,0 +1,161 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth + +import androidx.compose.animation.core.LinearEasing +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.scaleIn +import androidx.compose.animation.slideOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.google.android.gms.tasks.OnCompleteListener +import com.google.android.gms.tasks.Task +import com.google.firebase.messaging.FirebaseMessaging +import ru.n08i40k.polytechnic.next.ui.AppRoute +import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar +import ru.n08i40k.polytechnic.next.ui.helper.SnackbarBox +import ru.n08i40k.polytechnic.next.worker.UpdateFCMTokenWorker + + +enum class AuthRoute(val route: String) { + SignUp("sign-up"), + SignIn("sign-in"), +} + +@Composable +private fun FormWrapper( + onWidthChange: (Dp) -> Unit, + content: @Composable BoxScope.() -> Unit +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Card(border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)) { + val localDensity = LocalDensity.current + Box( + Modifier + .padding(10.dp) + .onGloballyPositioned { + with(localDensity) { + onWidthChange(it.size.width.toDp()) + } + } + /*.animateContentSize()*/, + content = content + ) + } + } +} + +@Composable +private fun AuthForm(parentNavController: NavController, pushSnackbar: PushSnackbar) { + val navController = rememberNavController() + val context = LocalContext.current + + val switch: () -> Unit = { + navController.navigate( + if (navController.currentDestination?.route == AuthRoute.SignUp.route) + AuthRoute.SignIn.route + else + AuthRoute.SignUp.route + ) + } + + val finish: () -> Unit = { + parentNavController.navigate(AppRoute.MAIN.route) { + popUpTo(AppRoute.AUTH.route) { inclusive = true } + } + + FirebaseMessaging.getInstance().token.addOnCompleteListener(object : + OnCompleteListener { + override fun onComplete(token: Task) { + if (!token.isSuccessful) + return + + UpdateFCMTokenWorker.schedule(context, token.result!!) + } + }) + } + + NavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = "sign-up", + enterTransition = { + fadeIn( + animationSpec = tween( + durationMillis = 700, + delayMillis = 800, + easing = LinearOutSlowInEasing + ) + ) + scaleIn( + animationSpec = tween( + durationMillis = 400, + delayMillis = 700, + easing = LinearOutSlowInEasing + ) + ) + }, + exitTransition = { + slideOut( + animationSpec = tween( + durationMillis = 250, + easing = LinearEasing + ) + ) { fullSize -> IntOffset(0, fullSize.height / 16) } + fadeOut( + animationSpec = tween( + durationMillis = 250, + easing = LinearEasing + ) + ) + }, + ) { + composable(AuthRoute.SignUp.route) { + var width by remember { mutableStateOf(Dp.Unspecified) } + + FormWrapper({ width = it }) { + SignUpForm(pushSnackbar, switch, finish, width) + } + } + + composable(AuthRoute.SignIn.route) { + var width by remember { mutableStateOf(Dp.Unspecified) } + + FormWrapper({ width = it }) { + SignInCard(pushSnackbar, switch, finish, width) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AuthScreen(navController: NavController) { + SnackbarBox { + Box(contentAlignment = Alignment.Center) { + AuthForm(navController, it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/SignInCard.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/SignInCard.kt new file mode 100644 index 0000000..a23adb8 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/SignInCard.kt @@ -0,0 +1,164 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth + +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Create +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.compose.rememberNavController +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar +import ru.n08i40k.polytechnic.next.ui.navigation.NavHostContainer +import ru.n08i40k.polytechnic.next.ui.screen.auth.signin.ManualPage +import ru.n08i40k.polytechnic.next.ui.screen.auth.signin.VKOneTap + + +private enum class SignInPage(val route: String) { + SELECT("select"), + MANUAL("manual"), +} + +@Preview(showBackground = true) +@Composable +private fun SelectSignInMethod( + onSelected: (SignInPage) -> Unit = {}, + switch: () -> Unit = {}, + toApp: () -> Unit = {}, + pushSnackbar: PushSnackbar = { _, _ -> }, +) { + val modifier = Modifier.width(240.dp) + var vkId by remember { mutableStateOf(false) } + + Column( + modifier, + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + stringResource(R.string.sign_in_title), + Modifier.padding(10.dp), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold + ) + + Box(modifier, contentAlignment = Alignment.Center) { + Button({ onSelected(SignInPage.MANUAL) }, modifier, !vkId) { + Text(stringResource(R.string.sign_in_manual), fontWeight = FontWeight.Bold) + } + Row(modifier.padding(10.dp, 0.dp)) { + Icon( + Icons.Filled.Create, + stringResource(R.string.cd_manual_icon), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + VKOneTap(toApp, pushSnackbar) { vkId = it } + + Box(modifier, contentAlignment = Alignment.Center) { + HorizontalDivider() + Text( + stringResource(R.string.or_divider), + Modifier.background(CardDefaults.cardColors().containerColor) + ) + } + + Button(switch, modifier, !vkId) { + Text(stringResource(R.string.sign_in_not_registered)) + } + } +} + +@Composable +fun SignInCard( + pushSnackbar: PushSnackbar, + toSignUp: () -> Unit, + toApp: () -> Unit, + parentWidth: Dp, +) { + val navHostController = rememberNavController() + + val toSelect: () -> Unit = { + navHostController.navigate(SignInPage.SELECT.route) { + popUpTo(SignInPage.SELECT.route) { inclusive = true } + } + } + + NavHostContainer( + navHostController, + PaddingValues(0.dp), + SignInPage.SELECT.route, + mapOf Unit>( + SignInPage.SELECT.route to { + SelectSignInMethod( + { page -> navHostController.navigate(page.route) }, + toSignUp, + toApp, + pushSnackbar + ) + }, + SignInPage.MANUAL.route to { + ManualPage( + pushSnackbar, + toApp, + toSelect, + parentWidth + ) + } + ), + enterTransition = { + slideIn( + animationSpec = tween( + 400, + delayMillis = 500, + easing = LinearOutSlowInEasing + ) + ) { fullSize -> IntOffset(0, -fullSize.height / 16) } + fadeIn( + animationSpec = tween( + 400, + delayMillis = 500, + easing = LinearOutSlowInEasing + ) + ) + }, + sizeTransform = { + SizeTransform { initialSize, targetSize -> + keyframes { + durationMillis = 250 + delayMillis = 250 + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/SignUpCard.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/SignUpCard.kt new file mode 100644 index 0000000..28b5aaa --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/SignUpCard.kt @@ -0,0 +1,164 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth + +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Create +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.compose.rememberNavController +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar +import ru.n08i40k.polytechnic.next.ui.navigation.NavHostContainer +import ru.n08i40k.polytechnic.next.ui.screen.auth.signup.ManualPage +import ru.n08i40k.polytechnic.next.ui.screen.auth.signup.VKPage +import ru.n08i40k.polytechnic.next.ui.widgets.OneTapComplete + +private enum class SignUpPage(val route: String) { + SELECT("select"), + MANUAL("manual"), + VK("vk") +} + +@Preview(showBackground = true) +@Composable +private fun SelectSignUpMethod( + onSelected: (SignUpPage, String?) -> Unit = { _, _ -> }, + switch: () -> Unit = {} +) { + val modifier = Modifier.width(240.dp) + + Column( + modifier, + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + stringResource(R.string.sign_up_title), + Modifier.padding(10.dp), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold + ) + + Box(modifier, contentAlignment = Alignment.Center) { + Button({ onSelected(SignUpPage.MANUAL, null) }, modifier) { + Text(stringResource(R.string.sign_up_manual), fontWeight = FontWeight.Bold) + } + Row(modifier.padding(10.dp, 0.dp)) { + Icon( + Icons.Filled.Create, + stringResource(R.string.cd_manual_icon), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + OneTapComplete(onAuth = { onSelected(SignUpPage.VK, it) }, onFail = {}) + + Box(modifier, contentAlignment = Alignment.Center) { + HorizontalDivider() + Text( + stringResource(R.string.or_divider), + Modifier.background(CardDefaults.cardColors().containerColor) + ) + } + + Button(switch, modifier) { + Text(stringResource(R.string.sign_up_already_registered)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun SignUpForm( + pushSnackbar: PushSnackbar = { msg, dur -> }, + toSignIn: () -> Unit = {}, + toApp: () -> Unit = {}, + parentWidth: Dp = Dp.Unspecified, +) { + val navHostController = rememberNavController() + + val toSelect: () -> Unit = { + navHostController.navigate(SignUpPage.SELECT.route) { + popUpTo(SignUpPage.SELECT.route) { inclusive = true } + } + } + + var accessToken by remember { mutableStateOf(null) } + + NavHostContainer( + navHostController, + PaddingValues(0.dp), + SignUpPage.SELECT.route, + mapOf Unit>( + SignUpPage.SELECT.route to { + SelectSignUpMethod( + { page, token -> + navHostController.navigate(page.route) + accessToken = token + }, + toSignIn + ) + }, + SignUpPage.MANUAL.route to { + ManualPage(pushSnackbar, toApp, toSelect, parentWidth) + }, + SignUpPage.VK.route to { + VKPage(accessToken!!, pushSnackbar, toApp, toSelect, parentWidth) + } + ), + enterTransition = { + slideIn( + animationSpec = tween( + 400, + delayMillis = 500, + easing = LinearOutSlowInEasing + ) + ) { fullSize -> IntOffset(0, -fullSize.height / 16) } + fadeIn( + animationSpec = tween( + 400, + delayMillis = 500, + easing = LinearOutSlowInEasing + ) + ) + }, + sizeTransform = { + SizeTransform { initialSize, targetSize -> + keyframes { + durationMillis = 250 + delayMillis = 250 + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/ManualPage.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/ManualPage.kt new file mode 100644 index 0000000..88278b4 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/ManualPage.kt @@ -0,0 +1,229 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth.signin + +import android.content.Context +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +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.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.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignIn +import ru.n08i40k.polytechnic.next.network.unwrapException +import ru.n08i40k.polytechnic.next.settings.settings +import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar +import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue +import java.util.logging.Logger + +private fun trySignIn( + context: Context, + + username: String, + password: String, + + onSuccess: () -> Unit, + onError: (SignInError) -> Unit, +) { + AuthSignIn( + AuthSignIn.RequestDto(username, password), + { + runBlocking { + context.settings.updateData { settings -> + settings + .toBuilder() + .setUserId(it.id) + .setAccessToken(it.accessToken) + .setGroup(it.group) + .build() + } + } + + onSuccess() + }, + { + val error = mapError(unwrapException(it)) + + if (error == SignInError.UNKNOWN) { + val logger = Logger.getLogger("tryRegister") + + logger.severe("Unknown exception while trying to register!") + logger.severe(it.toString()) + } + + onError(error) + } + ).send(context) +} + + +@Composable +internal fun ManualPage( + pushSnackbar: PushSnackbar, + toApp: () -> Unit, + toSelect: () -> Unit, + parentWidth: Dp, +) { + val context = LocalContext.current + + var username by rememberInputValue("") { it.length < 4 } + var password by rememberInputValue("") { it.isEmpty() } + + var invalidCredentials by remember { mutableStateOf(false) } + + var loading by remember { mutableStateOf(false) } + + val focusManager = LocalFocusManager.current + + val onClick: () -> Unit = fun() { + focusManager.clearFocus(true) + + loading = true + + trySignIn( + context, + username.value, + password.value, + { + loading = false + toApp() + }, + { + loading = false + + when (it) { + SignInError.INCORRECT_CREDENTIALS -> username = username.copy(isError = true) + else -> Unit + } + + pushSnackbar(getErrorMessage(context, it, false), SnackbarDuration.Long) + } + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier.defaultMinSize(parentWidth, Dp.Unspecified), + contentAlignment = Alignment.Center + ) { + if (parentWidth != Dp.Unspecified) { + Row(Modifier.width(parentWidth)) { + IconButton(toSelect) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + stringResource(R.string.cd_back_icon) + ) + } + } + } + + Text( + stringResource(R.string.sign_in_title), + Modifier.padding(10.dp), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold + ) + } + + val onValueChange: () -> Unit = { + if (invalidCredentials) { + invalidCredentials = false + + username = username.copy(isError = false, checkNow = true) + password = password.copy(isError = false, checkNow = true) + } + } + + OutlinedTextField( + username.value, + { + onValueChange() + + username = username.copy( + it.filter { it != ' ' }.lowercase(), + isError = false + ) + }, + readOnly = loading, + label = { Text(stringResource(R.string.username)) }, + isError = username.isError, + singleLine = true, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrectEnabled = false, + KeyboardType.Ascii, + ImeAction.Next + ) + ) + + OutlinedTextField( + password.value, + { + onValueChange() + + password = password.copy( + it, + isError = false + ) + }, + readOnly = loading, + label = { Text(stringResource(R.string.password)) }, + isError = password.isError, + singleLine = true, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrectEnabled = false, + KeyboardType.Password, + ImeAction.Next + ), + visualTransformation = PasswordVisualTransformation() + ) + + if (parentWidth != Dp.Unspecified) { + Spacer(Modifier.height(10.dp)) + + val canProceed = !loading + && (!username.isError && username.value.isNotEmpty()) + && (!password.isError && password.value.isNotEmpty()) + Button( + onClick, + Modifier.width(parentWidth), + enabled = canProceed + ) { + Text( + stringResource(R.string.proceed), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/SignInError.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/SignInError.kt new file mode 100644 index 0000000..e7802ab --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/SignInError.kt @@ -0,0 +1,62 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth.signin + +import android.content.Context +import com.android.volley.ClientError +import com.android.volley.NoConnectionError +import com.android.volley.TimeoutError +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignIn +import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignIn.Companion.ErrorCode +import java.util.concurrent.TimeoutException + +enum class SignInError { + // server errors + INCORRECT_CREDENTIALS, + INVALID_VK_ACCESS_TOKEN, + + // client errors + TIMED_OUT, + NO_CONNECTION, + + UNKNOWN +} + +fun mapError(exception: Throwable): SignInError { + return when (exception) { + is TimeoutException -> SignInError.TIMED_OUT + is TimeoutError -> SignInError.TIMED_OUT + + is NoConnectionError -> SignInError.NO_CONNECTION + + is ClientError -> { + if (exception.networkResponse.statusCode != 406) + return SignInError.UNKNOWN + + val error = AuthSignIn.Companion.parseError(exception) + + when (error.code) { + ErrorCode.INVALID_VK_ACCESS_TOKEN -> SignInError.INVALID_VK_ACCESS_TOKEN + ErrorCode.INCORRECT_CREDENTIALS -> SignInError.INCORRECT_CREDENTIALS + } + } + + else -> SignInError.UNKNOWN + } +} + +fun getErrorMessage(context: Context, error: SignInError, isVK: Boolean): String { + return context.getString( + when (error) { + SignInError.UNKNOWN -> R.string.unknown_error + SignInError.INVALID_VK_ACCESS_TOKEN -> R.string.auth_error_invalid_vk_access_token + SignInError.INCORRECT_CREDENTIALS -> + if (isVK) + R.string.auth_error_vk_not_linked + else + R.string.auth_error_incorrect_credentials + + SignInError.TIMED_OUT -> R.string.timed_out + SignInError.NO_CONNECTION -> R.string.no_connection + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/VKOneTap.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/VKOneTap.kt new file mode 100644 index 0000000..ca47f65 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signin/VKOneTap.kt @@ -0,0 +1,80 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth.signin + +import android.content.Context +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.runBlocking +import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignInVK +import ru.n08i40k.polytechnic.next.network.unwrapException +import ru.n08i40k.polytechnic.next.settings.settings +import ru.n08i40k.polytechnic.next.ui.widgets.OneTapComplete +import java.util.logging.Logger + +private fun trySignIn( + context: Context, + + accessToken: String, + + onSuccess: () -> Unit, + onError: (SignInError) -> Unit, +) { + AuthSignInVK( + AuthSignInVK.RequestDto(accessToken), + { + runBlocking { + context.settings.updateData { settings -> + settings + .toBuilder() + .setUserId(it.id) + .setAccessToken(it.accessToken) + .setGroup(it.group) + .build() + } + } + + onSuccess() + }, + { + val error = mapError(unwrapException(it)) + + if (error == SignInError.UNKNOWN) { + val logger = Logger.getLogger("trySignIn") + + logger.severe("Unknown exception while trying to sign-in!") + logger.severe(it.toString()) + } + + onError(error) + } + ).send(context) +} + + +@Preview(showBackground = true) +@Composable +fun VKOneTap( + toApp: () -> Unit = {}, + pushSnackbar: (String, SnackbarDuration) -> Unit = { _, _ -> }, + + onProcess: (Boolean) -> Unit = {} +) { + val context = LocalContext.current + + OneTapComplete( + onAuth = { + onProcess(true) + + trySignIn( + context, + it, + toApp + ) { + pushSnackbar(getErrorMessage(context, it, true), SnackbarDuration.Long) + onProcess(false) + } + }, + onFail = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/ManualPage.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/ManualPage.kt new file mode 100644 index 0000000..d074776 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/ManualPage.kt @@ -0,0 +1,276 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth.signup + +import android.content.Context +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +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.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.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import ru.n08i40k.polytechnic.next.Application +import ru.n08i40k.polytechnic.next.R +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.settings +import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar +import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue +import ru.n08i40k.polytechnic.next.ui.widgets.selector.GroupSelector +import ru.n08i40k.polytechnic.next.ui.widgets.selector.RoleSelector +import ru.n08i40k.polytechnic.next.ui.widgets.selector.TeacherNameSelector +import java.util.logging.Logger + +private fun trySignUp( + context: Context, + + username: String, + password: String, + group: String, + role: UserRole, + + onSuccess: () -> Unit, + onError: (SignUpError) -> Unit, +) { + AuthSignUp( + AuthSignUp.RequestDto( + username, + password, + group, + role, + (context.applicationContext as Application).version + ), + { + runBlocking { + context.settings.updateData { settings -> + settings + .toBuilder() + .setUserId(it.id) + .setAccessToken(it.accessToken) + .setGroup(group) + .build() + } + } + + onSuccess() + }, + { + val error = mapError(unwrapException(it)) + + if (error == SignUpError.UNKNOWN) { + val logger = Logger.getLogger("tryRegister") + + logger.severe("Unknown exception while trying to register!") + logger.severe(it.toString()) + } + + onError(error) + } + ).send(context) +} + + +@Composable +internal fun ManualPage( + pushSnackbar: PushSnackbar, + toApp: () -> Unit, + toSelect: () -> Unit, + parentWidth: Dp, +) { + val context = LocalContext.current + + var username by rememberInputValue("") { it.length < 4 } + var password by rememberInputValue("") { it.isEmpty() } + var group by rememberInputValue(null) { it == null } + var role by remember { mutableStateOf(UserRole.STUDENT) } + + var loading by remember { mutableStateOf(false) } + + val focusManager = LocalFocusManager.current + + val onClick: () -> Unit = fun() { + focusManager.clearFocus(true) + + loading = true + + trySignUp( + context, + username.value, + password.value, + group.value!!, + role, + { + loading = false + toApp() + }, + { + loading = false + + when (it) { + SignUpError.USERNAME_ALREADY_EXISTS -> username = username.copy(isError = true) + SignUpError.INVALID_GROUP_NAME -> group = group.copy(isError = true) + else -> Unit + } + + pushSnackbar(getErrorMessage(context, it), SnackbarDuration.Long) + } + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier.defaultMinSize(parentWidth, Dp.Unspecified), + contentAlignment = Alignment.Center + ) { + if (parentWidth != Dp.Unspecified) { + Row(Modifier.width(parentWidth)) { + IconButton(toSelect) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + stringResource(R.string.cd_back_icon) + ) + } + } + } + + Text( + stringResource(R.string.sign_up_title), + Modifier.padding(10.dp), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold + ) + } + + when (role) { + UserRole.TEACHER -> { + TeacherNameSelector( + username.value, { + username = username.copy( + value = it, + isError = false + ) + }, + isError = username.isError, + readOnly = loading + ) + } + + UserRole.STUDENT -> { + OutlinedTextField( + username.value, + { + username = username.copy( + value = it.filter { it != ' ' }.lowercase(), + isError = false + ) + }, + readOnly = loading, + label = { Text(stringResource(R.string.username)) }, + isError = username.isError, + singleLine = true, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrectEnabled = false, + KeyboardType.Ascii, + ImeAction.Next + ) + ) + } + + else -> Unit + } + + OutlinedTextField( + password.value, + { + password = password.copy( + value = it, + isError = false + ) + }, + readOnly = loading, + label = { Text(stringResource(R.string.password)) }, + isError = password.isError, + singleLine = true, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrectEnabled = false, + KeyboardType.Password, + ImeAction.Next + ), + visualTransformation = PasswordVisualTransformation(), + ) + + Spacer(Modifier.height(10.dp)) + + GroupSelector( + group.value, + { + group = group.copy( + value = it, + isError = false + ) + }, + isError = group.isError, + readOnly = loading, + supervised = role == UserRole.TEACHER + ) + + Spacer(Modifier.height(10.dp)) + + RoleSelector( + role, + false, + loading + ) { role = it } + + if (parentWidth != Dp.Unspecified) { + Spacer(Modifier.height(10.dp)) + + val canProceed = !loading + && (!username.isError && username.value.isNotEmpty()) + && (!password.isError && password.value.isNotEmpty()) + && (!group.isError && group.value != null) + + Button( + onClick, + Modifier.width(parentWidth), + enabled = canProceed + ) { + Text( + stringResource(R.string.proceed), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/SignUpError.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/SignUpError.kt new file mode 100644 index 0000000..8ba40e5 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/SignUpError.kt @@ -0,0 +1,66 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth.signup + +import android.content.Context +import com.android.volley.ClientError +import com.android.volley.NoConnectionError +import com.android.volley.TimeoutError +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUp +import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUp.Companion.ErrorCode +import java.util.concurrent.TimeoutException + +enum class SignUpError { + // server errors + USERNAME_ALREADY_EXISTS, + VK_ALREADY_EXISTS, + INVALID_VK_ACCESS_TOKEN, + INVALID_GROUP_NAME, + DISALLOWED_ROLE, + + // client errors + TIMED_OUT, + NO_CONNECTION, + + UNKNOWN +} + +fun mapError(exception: Throwable): SignUpError { + return when (exception) { + is TimeoutException -> SignUpError.TIMED_OUT + is TimeoutError -> SignUpError.TIMED_OUT + + is NoConnectionError -> SignUpError.NO_CONNECTION + + is ClientError -> { + if (exception.networkResponse.statusCode != 406) + return SignUpError.UNKNOWN + + val error = AuthSignUp.Companion.parseError(exception) + + when (error.code) { + ErrorCode.USERNAME_ALREADY_EXISTS -> SignUpError.USERNAME_ALREADY_EXISTS + ErrorCode.VK_ALREADY_EXISTS -> SignUpError.VK_ALREADY_EXISTS + ErrorCode.INVALID_VK_ACCESS_TOKEN -> SignUpError.INVALID_VK_ACCESS_TOKEN + ErrorCode.INVALID_GROUP_NAME -> SignUpError.INVALID_GROUP_NAME + ErrorCode.DISALLOWED_ROLE -> SignUpError.DISALLOWED_ROLE + } + } + + else -> SignUpError.UNKNOWN + } +} + +fun getErrorMessage(context: Context, error: SignUpError): String { + return context.getString( + when (error) { + SignUpError.UNKNOWN -> R.string.unknown_error + SignUpError.USERNAME_ALREADY_EXISTS -> R.string.auth_error_username_already_exists + SignUpError.VK_ALREADY_EXISTS -> R.string.auth_error_vk_already_exists + SignUpError.INVALID_VK_ACCESS_TOKEN -> R.string.auth_error_invalid_vk_access_token + SignUpError.INVALID_GROUP_NAME -> R.string.auth_error_invalid_group_name + SignUpError.DISALLOWED_ROLE -> R.string.auth_error_disallowed_role + SignUpError.TIMED_OUT -> R.string.timed_out + SignUpError.NO_CONNECTION -> R.string.no_connection + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/VKPage.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/VKPage.kt new file mode 100644 index 0000000..d9b7f76 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/auth/signup/VKPage.kt @@ -0,0 +1,253 @@ +package ru.n08i40k.polytechnic.next.ui.screen.auth.signup + +import android.content.Context +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +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.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.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import ru.n08i40k.polytechnic.next.Application +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.model.UserRole +import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUpVK +import ru.n08i40k.polytechnic.next.network.unwrapException +import ru.n08i40k.polytechnic.next.settings.settings +import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar +import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue +import ru.n08i40k.polytechnic.next.ui.widgets.selector.GroupSelector +import ru.n08i40k.polytechnic.next.ui.widgets.selector.RoleSelector +import ru.n08i40k.polytechnic.next.ui.widgets.selector.TeacherNameSelector +import java.util.logging.Logger + +private fun trySignUp( + context: Context, + + accessToken: String, + + username: String, + group: String, + role: UserRole, + + onSuccess: () -> Unit, + onError: (SignUpError) -> Unit, +) { + AuthSignUpVK( + AuthSignUpVK.RequestDto( + accessToken, + username, + group, + role, + (context.applicationContext as Application).version + ), + { + runBlocking { + context.settings.updateData { settings -> + settings + .toBuilder() + .setUserId(it.id) + .setAccessToken(it.accessToken) + .setGroup(group) + .build() + } + } + + onSuccess() + }, + { + val error = mapError(unwrapException(it)) + + if (error == SignUpError.UNKNOWN) { + val logger = Logger.getLogger("trySignUp") + + logger.severe("Unknown exception while trying to sign up!") + logger.severe(it.toString()) + } + + onError(error) + } + ).send(context) +} + +@Composable +internal fun VKPage( + accessToken: String, + pushSnackbar: PushSnackbar, + toApp: () -> Unit, + toSelect: () -> Unit, + parentWidth: Dp, +) { + val context = LocalContext.current + + var username by rememberInputValue("") { it.length < 4 } + var group by rememberInputValue(null) { it == null } + var role by remember { mutableStateOf(UserRole.STUDENT) } + + var loading by remember { mutableStateOf(false) } + + val focusManager = LocalFocusManager.current + + val onClick: () -> Unit = fun() { + focusManager.clearFocus(true) + + loading = true + + trySignUp( + context, + accessToken, + username.value, + group.value!!, + role, + { + loading = false + toApp() + }, + { + loading = false + + when (it) { + SignUpError.USERNAME_ALREADY_EXISTS -> username = username.copy(isError = true) + SignUpError.INVALID_GROUP_NAME -> group = group.copy(isError = true) + else -> Unit + } + + pushSnackbar(getErrorMessage(context, it), SnackbarDuration.Long) + } + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier.defaultMinSize(parentWidth, Dp.Unspecified), + contentAlignment = Alignment.Center + ) { + if (parentWidth != Dp.Unspecified) { + Row(Modifier.width(parentWidth)) { + IconButton(toSelect) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + stringResource(R.string.cd_back_icon) + ) + } + } + } + + Text( + stringResource(R.string.sign_up_title), + Modifier.padding(10.dp), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold + ) + } + + when (role) { + UserRole.TEACHER -> { + TeacherNameSelector( + username.value, + { + username = username.copy( + value = it, + isError = false + ) + }, + isError = username.isError, + readOnly = loading + ) + } + + UserRole.STUDENT -> { + OutlinedTextField( + username.value, + { + username = username.copy( + value = it.filter { it != ' ' }.lowercase(), + isError = false + ) + }, + readOnly = loading, + label = { Text(stringResource(R.string.username)) }, + isError = username.isError, + singleLine = true, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrectEnabled = false, + KeyboardType.Ascii, + ImeAction.Next + ) + ) + } + + else -> Unit + } + + Spacer(Modifier.height(10.dp)) + + GroupSelector( + group.value, { + group = group.copy( + value = it, + isError = false + ) + }, + isError = group.isError, + readOnly = loading, + supervised = role == UserRole.TEACHER + ) + + Spacer(Modifier.height(10.dp)) + + RoleSelector( + role, + false, + loading + ) { role = it } + + if (parentWidth != Dp.Unspecified) { + Spacer(Modifier.height(10.dp)) + + val canProceed = !loading + && (!username.isError && username.value.isNotEmpty()) + && (!group.isError && group.value != null) + + Button( + onClick, + Modifier.width(parentWidth), + enabled = canProceed + ) { + Text( + stringResource(R.string.proceed), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeGroupDialog.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangeGroupDialog.kt similarity index 64% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeGroupDialog.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangeGroupDialog.kt index ada7752..ef461b4 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeGroupDialog.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangeGroupDialog.kt @@ -1,4 +1,4 @@ -package ru.n08i40k.polytechnic.next.ui.main.profile +package ru.n08i40k.polytechnic.next.ui.screen.profile import android.content.Context import androidx.compose.foundation.layout.Column @@ -21,11 +21,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.Dialog 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.app.appContainer import ru.n08i40k.polytechnic.next.model.Profile import ru.n08i40k.polytechnic.next.model.UserRole import ru.n08i40k.polytechnic.next.network.request.profile.ProfileChangeGroup -import ru.n08i40k.polytechnic.next.ui.widgets.GroupSelector +import ru.n08i40k.polytechnic.next.repository.profile.impl.MockProfileRepository +import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue +import ru.n08i40k.polytechnic.next.ui.widgets.selector.GroupSelector private enum class ChangeGroupError { NOT_EXISTS @@ -35,29 +37,31 @@ private fun tryChangeGroup( context: Context, group: String, onError: (ChangeGroupError) -> Unit, - onSuccess: (String) -> Unit + onSuccess: () -> Unit ) { - ProfileChangeGroup(ProfileChangeGroup.RequestDto(group), context, { - onSuccess(group) - }, { - if (it is ClientError && it.networkResponse.statusCode == 404) - onError(ChangeGroupError.NOT_EXISTS) - else throw it - }).send() + ProfileChangeGroup( + context.appContainer, + ProfileChangeGroup.RequestDto(group), + { onSuccess() }, + { + if (it is ClientError && it.networkResponse.statusCode == 404) + onError(ChangeGroupError.NOT_EXISTS) + else throw it + } + ).send(context) } @Preview(showBackground = true) @Composable internal fun ChangeGroupDialog( context: Context = LocalContext.current, - profile: Profile = FakeProfileRepository.exampleProfile, - onChange: (String) -> Unit = {}, + profile: Profile = MockProfileRepository.profile, + onChange: () -> Unit = {}, onDismiss: () -> Unit = {} ) { Dialog(onDismissRequest = onDismiss) { Card { - var group by remember { mutableStateOf(profile.group) } - var groupError by remember { mutableStateOf(false) } + var group by rememberInputValue(profile.group) { it == null } var processing by remember { mutableStateOf(false) } @@ -65,35 +69,32 @@ internal fun ChangeGroupDialog( val modifier = Modifier.fillMaxWidth() GroupSelector( - value = group, - isError = groupError, + group.value, + { group = group.copy(value = it, isError = false) }, + isError = group.isError, readOnly = processing, - teacher = profile.role == UserRole.TEACHER - ) { group = it } + supervised = profile.role == UserRole.TEACHER + ) val focusManager = LocalFocusManager.current Button( - modifier = modifier, - onClick = { + { processing = true focusManager.clearFocus() tryChangeGroup( context = context, - group = group!!, + group = group.value!!, onError = { - when (it) { - ChangeGroupError.NOT_EXISTS -> { - groupError = true - } - } + group = group.copy(isError = true) processing = false }, onSuccess = onChange ) }, - enabled = !(groupError || processing) && group != null + modifier, + !(group.isError || processing) && group.value != null ) { Text(stringResource(R.string.change_group)) } diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangePasswordDialog.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangePasswordDialog.kt new file mode 100644 index 0000000..9bcd54e --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangePasswordDialog.kt @@ -0,0 +1,149 @@ +package ru.n08i40k.polytechnic.next.ui.screen.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.foundation.text.KeyboardOptions +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.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.Dialog +import com.android.volley.AuthFailureError +import com.android.volley.ClientError +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.app.appContainer +import ru.n08i40k.polytechnic.next.network.request.auth.AuthChangePassword +import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue + +private enum class ChangePasswordError { + INCORRECT_CURRENT_PASSWORD, + SAME_PASSWORDS +} + +private fun tryChangePassword( + context: Context, + oldPassword: String, + newPassword: String, + onSuccess: () -> Unit, + onError: (ChangePasswordError) -> Unit +) { + AuthChangePassword( + context.appContainer, + AuthChangePassword.RequestDto(oldPassword, newPassword), + { 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(context) +} + +@Preview(showBackground = true) +@Composable +internal fun ChangePasswordDialog( + context: Context = LocalContext.current, + onChange: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + Dialog(onDismissRequest = onDismiss) { + Card { + var oldPassword by rememberInputValue("") { it.isEmpty() } + var newPassword by rememberInputValue("") { it.isEmpty() || it == oldPassword.value } + + var processing by remember { mutableStateOf(false) } + + Column(modifier = Modifier.width(IntrinsicSize.Max)) { + val modifier = Modifier.fillMaxWidth() + + OutlinedTextField( + oldPassword.value, + { + oldPassword = oldPassword.copy(value = it, isError = false) + newPassword = newPassword.copy(isError = false, checkNow = true) + }, + modifier, + readOnly = processing, + label = { Text(text = stringResource(R.string.old_password)) }, + isError = oldPassword.isError, + singleLine = true, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrectEnabled = false, + KeyboardType.Password, + ImeAction.Next + ), + visualTransformation = PasswordVisualTransformation() + ) + + OutlinedTextField( + newPassword.value, + { newPassword = newPassword.copy(value = it, isError = false) }, + modifier, + readOnly = processing, + label = { Text(text = stringResource(R.string.new_password)) }, + isError = newPassword.isError, + singleLine = true, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrectEnabled = false, + KeyboardType.Password, + ImeAction.Next + ), + visualTransformation = PasswordVisualTransformation() + ) + + val focusManager = LocalFocusManager.current + Button( + { + processing = true + focusManager.clearFocus() + + tryChangePassword( + context, + oldPassword.value, + newPassword.value, + onChange + ) { + when (it) { + ChangePasswordError.SAME_PASSWORDS -> { + oldPassword.copy(isError = true) + newPassword.copy(isError = true) + } + + ChangePasswordError.INCORRECT_CURRENT_PASSWORD -> { + oldPassword.isError = true + } + } + + processing = false + } + }, + modifier, + !(newPassword.isError || oldPassword.isError || processing) + ) { + Text(stringResource(R.string.change_password)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeUsernameDialog.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangeUsernameDialog.kt similarity index 60% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeUsernameDialog.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangeUsernameDialog.kt index 70e718d..abe9a23 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeUsernameDialog.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ChangeUsernameDialog.kt @@ -1,10 +1,11 @@ -package ru.n08i40k.polytechnic.next.ui.main.profile +package ru.n08i40k.polytechnic.next.ui.screen.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.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.OutlinedTextField @@ -18,13 +19,16 @@ 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.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.Dialog 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.app.appContainer import ru.n08i40k.polytechnic.next.network.request.profile.ProfileChangeUsername +import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue private enum class ChangeUsernameError { INCORRECT_LENGTH, @@ -37,29 +41,32 @@ private fun tryChangeUsername( onError: (ChangeUsernameError) -> Unit, onSuccess: () -> Unit ) { - ProfileChangeUsername(ProfileChangeUsername.RequestDto(username), context, { - onSuccess() - }, { - if (it is ClientError && it.networkResponse.statusCode == 409) - onError(ChangeUsernameError.ALREADY_EXISTS) - if (it is ClientError && it.networkResponse.statusCode == 400) - onError(ChangeUsernameError.INCORRECT_LENGTH) - else throw it - }).send() + ProfileChangeUsername( + context.appContainer, + ProfileChangeUsername.RequestDto(username), + { + onSuccess() + }, + { + if (it is ClientError && it.networkResponse.statusCode == 409) + onError(ChangeUsernameError.ALREADY_EXISTS) + if (it is ClientError && it.networkResponse.statusCode == 400) + onError(ChangeUsernameError.INCORRECT_LENGTH) + else throw it + } + ).send(context) } @Preview(showBackground = true) @Composable internal fun ChangeUsernameDialog( context: Context = LocalContext.current, - profile: Profile = FakeProfileRepository.exampleProfile, onChange: () -> Unit = {}, onDismiss: () -> Unit = {} ) { Dialog(onDismissRequest = onDismiss) { Card { - var username by remember { mutableStateOf("") } - var usernameError by remember { mutableStateOf(false) } + var username by rememberInputValue("") { it.length < 4 } var processing by remember { mutableStateOf(false) } @@ -67,18 +74,19 @@ internal fun ChangeUsernameDialog( val modifier = Modifier.fillMaxWidth() OutlinedTextField( - modifier = modifier, - value = username, - isError = usernameError, - onValueChange = { - username = it - usernameError = it.isEmpty() - || username == profile.username - || username.length < 4 - || username.length > 10 - }, + username.value, + { username = username.copy(value = it.filter { it != ' ' }.lowercase()) }, + modifier, + readOnly = processing, label = { Text(text = stringResource(R.string.username)) }, - readOnly = processing + isError = username.isError, + singleLine = true, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrectEnabled = false, + KeyboardType.Ascii, + ImeAction.Next + ) ) val focusManager = LocalFocusManager.current @@ -90,19 +98,15 @@ internal fun ChangeUsernameDialog( tryChangeUsername( context = context, - username = username, + username = username.value, onError = { - usernameError = when (it) { - ChangeUsernameError.ALREADY_EXISTS -> true - ChangeUsernameError.INCORRECT_LENGTH -> true - } - + username = username.copy(isError = true) processing = false }, onSuccess = onChange ) }, - enabled = !(usernameError || processing) + enabled = !(username.isError || processing) ) { Text(stringResource(R.string.change_username)) } diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ProfileCard.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ProfileCard.kt new file mode 100644 index 0000000..46204e3 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ProfileCard.kt @@ -0,0 +1,162 @@ +package ru.n08i40k.polytechnic.next.ui.screen.profile + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +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 kotlinx.coroutines.runBlocking +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.app.appContainer +import ru.n08i40k.polytechnic.next.model.Profile +import ru.n08i40k.polytechnic.next.model.UserRole +import ru.n08i40k.polytechnic.next.repository.profile.impl.MockProfileRepository + +private enum class ChangeValue { + NONE, + USERNAME, + PASSWORD, + GROUP +} + +@Preview(showSystemUi = true) +@Composable +fun ProfileCard(profile: Profile = MockProfileRepository.profile, refresh: () -> Unit = {}) { + Box(Modifier.padding(20.dp)) { + Card( + colors = CardDefaults.cardColors( + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + var columnSize by remember { mutableStateOf(10.dp) } + val localDensity = LocalDensity.current + + Column( + modifier = Modifier + .wrapContentWidth() + .padding(10.dp) + .onGloballyPositioned { + with(localDensity) { + columnSize = it.size.width.toDp() + } + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + var change by remember { mutableStateOf(ChangeValue.NONE) } + + TextField( + profile.username, + {}, + Modifier.onFocusChanged { + if (it.isFocused && profile.role !== UserRole.TEACHER) { + change = ChangeValue.USERNAME + focusManager.clearFocus(true) + } + }, + readOnly = true, + label = { Text(stringResource(R.string.username)) }, + leadingIcon = { + Icon(Icons.Filled.AccountCircle, stringResource(R.string.cd_profile_icon)) + } + ) + + TextField( + "12345678", + {}, + Modifier.onFocusChanged { + if (it.isFocused) { + change = ChangeValue.PASSWORD + focusManager.clearFocus(true) + } + }, + readOnly = true, + label = { Text(stringResource(R.string.password)) }, + leadingIcon = { + Icon(Icons.Filled.Lock, stringResource(R.string.cd_password_icon)) + }, + visualTransformation = PasswordVisualTransformation(), + ) + + TextField( + stringResource(profile.role.stringId), + {}, + readOnly = true, + label = { Text(stringResource(R.string.role)) }, + leadingIcon = { + Icon(profile.role.icon, stringResource(R.string.cd_role_icon)) + }, + ) + + TextField( + profile.group, + {}, + Modifier.onFocusChanged { + if (it.isFocused) { + change = ChangeValue.GROUP + focusManager.clearFocus() + } + }, + true, + label = { Text(stringResource(R.string.group)) }, + leadingIcon = { + Icon(Icons.Filled.Email, stringResource(R.string.cd_group_icon)) + }, + ) + + Button({ + val repo = context.applicationContext.appContainer.profileRepository + runBlocking { repo.signOut() } + }, Modifier.width(columnSize)) { + Text(stringResource(R.string.sign_out)) + } + + val onDismiss: () -> Unit = { + change = ChangeValue.NONE + } + + val onChange: () -> Unit = { + change = ChangeValue.NONE + refresh() + } + + when (change) { + ChangeValue.NONE -> Unit + ChangeValue.USERNAME -> ChangeUsernameDialog(context, onChange, onDismiss) + ChangeValue.PASSWORD -> ChangePasswordDialog(context, onChange, onDismiss) + ChangeValue.GROUP -> ChangeGroupDialog(context, profile, onChange, onDismiss) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ProfileScreen.kt similarity index 53% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileScreen.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ProfileScreen.kt index 9aaedae..e145038 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileScreen.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/profile/ProfileScreen.kt @@ -1,53 +1,59 @@ -package ru.n08i40k.polytechnic.next.ui.main.profile +package ru.n08i40k.polytechnic.next.ui.screen.profile import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +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.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.runBlocking import ru.n08i40k.polytechnic.next.R -import ru.n08i40k.polytechnic.next.data.MockAppContainer -import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent +import ru.n08i40k.polytechnic.next.app.appContainer import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel +import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent - -@Preview(showBackground = true) @Composable -fun ProfileScreen( - profileViewModel: ProfileViewModel = ProfileViewModel(MockAppContainer(LocalContext.current).profileRepository) {}, - onRefreshProfile: () -> Unit = {} -) { +fun ProfileScreen(profileViewModel: ProfileViewModel) { val uiState by profileViewModel.uiState.collectAsStateWithLifecycle() + val onRefresh: () -> Unit = { profileViewModel.refresh() } LoadingContent( empty = when (uiState) { - is ProfileUiState.NoProfile -> uiState.isLoading - is ProfileUiState.HasProfile -> false + is ProfileUiState.NoData -> uiState.isLoading + is ProfileUiState.HasData -> false }, loading = uiState.isLoading, - onRefresh = onRefreshProfile, + onRefresh = { profileViewModel.refresh() }, verticalArrangement = Arrangement.Top ) { when (uiState) { - is ProfileUiState.HasProfile -> { - ProfileCard((uiState as ProfileUiState.HasProfile).profile) + is ProfileUiState.HasData -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val context = LocalContext.current + + ProfileCard((uiState as ProfileUiState.HasData).profile) { + runBlocking { context.appContainer.networkCacheRepository.clear() } + + profileViewModel.refresh() + } + } } - is ProfileUiState.NoProfile -> { + is ProfileUiState.NoData -> { if (!uiState.isLoading) { - TextButton(onClick = onRefreshProfile, modifier = Modifier.fillMaxSize()) { + TextButton(onClick = onRefresh, modifier = Modifier.fillMaxSize()) { Text(stringResource(R.string.reload), textAlign = TextAlign.Center) } } } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/replacer/ReplacerScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/replacer/ReplacerScreen.kt new file mode 100644 index 0000000..bff3113 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/replacer/ReplacerScreen.kt @@ -0,0 +1,8 @@ +package ru.n08i40k.polytechnic.next.ui.screen.replacer + +import androidx.compose.runtime.Composable + +@Composable +fun ReplacerScreen() { + +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/GroupScheduleScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/GroupScheduleScreen.kt new file mode 100644 index 0000000..8cb10f8 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/GroupScheduleScreen.kt @@ -0,0 +1,83 @@ +package ru.n08i40k.polytechnic.next.ui.screen.schedule + +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.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.ui.model.GroupUiState +import ru.n08i40k.polytechnic.next.ui.model.GroupViewModel +import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent +import ru.n08i40k.polytechnic.next.ui.widgets.schedule.SchedulePager +import ru.n08i40k.polytechnic.next.utils.rememberUpdatedLifecycleOwner + +@Composable +fun GroupScheduleScreen(viewModel: GroupViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val refresh: () -> Unit = { viewModel.refresh() } + + // auto-refresh every 2 minutes + LaunchedEffect(uiState) { + delay(120_000) + refresh() + } + + val lifecycleOwner = rememberUpdatedLifecycleOwner() + + // обновление при развороте приложения + DisposableEffect(lifecycleOwner) { + val lifecycle = lifecycleOwner.lifecycle + + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> refresh() + else -> Unit + } + } + + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + LoadingContent( + empty = uiState is GroupUiState.NoData && uiState.isLoading, + loading = uiState.isLoading, + onRefresh = refresh, + verticalArrangement = Arrangement.Top + ) { + when (uiState) { + is GroupUiState.HasData -> { + Column { + val data = uiState as GroupUiState.HasData + + UpdateInfo(data.lastUpdateAt, data.updateDates) + Spacer(Modifier.height(10.dp)) + SchedulePager(data.group) + } + } + + else -> { + if (!uiState.isLoading) { + TextButton(refresh, Modifier.fillMaxSize()) { + Text(stringResource(R.string.reload), textAlign = TextAlign.Center) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/Paskhalko.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/Paskhalko.kt similarity index 90% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/Paskhalko.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/Paskhalko.kt index 2b86bf1..4ea6d08 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/Paskhalko.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/Paskhalko.kt @@ -1,4 +1,4 @@ -package ru.n08i40k.polytechnic.next.ui.main.schedule.group +package ru.n08i40k.polytechnic.next.ui.screen.schedule import androidx.compose.foundation.Image import androidx.compose.runtime.Composable diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/TeacherScheduleScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/TeacherScheduleScreen.kt new file mode 100644 index 0000000..448ef92 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/TeacherScheduleScreen.kt @@ -0,0 +1,83 @@ +package ru.n08i40k.polytechnic.next.ui.screen.schedule + +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.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.ui.model.TeacherUiState +import ru.n08i40k.polytechnic.next.ui.model.TeacherViewModel +import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent +import ru.n08i40k.polytechnic.next.ui.widgets.schedule.SchedulePager +import ru.n08i40k.polytechnic.next.utils.rememberUpdatedLifecycleOwner + +@Composable +fun TeacherScheduleScreen(viewModel: TeacherViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val refresh: () -> Unit = { viewModel.refresh() } + + // auto-refresh every 2 minutes + LaunchedEffect(uiState) { + delay(120_000) + refresh() + } + + val lifecycleOwner = rememberUpdatedLifecycleOwner() + + // обновление при развороте приложения + DisposableEffect(lifecycleOwner) { + val lifecycle = lifecycleOwner.lifecycle + + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> refresh() + else -> Unit + } + } + + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + LoadingContent( + empty = uiState is TeacherUiState.NoData && uiState.isLoading, + loading = uiState.isLoading, + onRefresh = refresh, + verticalArrangement = Arrangement.Top + ) { + when (uiState) { + is TeacherUiState.HasData -> { + Column { + val data = uiState as TeacherUiState.HasData + + UpdateInfo(data.lastUpdateAt, data.updateDates) + Spacer(Modifier.height(10.dp)) + SchedulePager(data.teacher) + } + } + + else -> { + if (!uiState.isLoading) { + TextButton(refresh, Modifier.fillMaxSize()) { + Text(stringResource(R.string.reload), textAlign = TextAlign.Center) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/TeacherSearchScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/TeacherSearchScreen.kt new file mode 100644 index 0000000..6271186 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/TeacherSearchScreen.kt @@ -0,0 +1,103 @@ +package ru.n08i40k.polytechnic.next.ui.screen.schedule + +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.fillMaxWidth +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.ui.model.SearchUiState +import ru.n08i40k.polytechnic.next.ui.model.SearchViewModel +import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent +import ru.n08i40k.polytechnic.next.ui.widgets.schedule.SchedulePager +import ru.n08i40k.polytechnic.next.ui.widgets.selector.TeacherNameSelector +import ru.n08i40k.polytechnic.next.utils.rememberUpdatedLifecycleOwner + +@Composable +fun TeacherSearchScreen(viewModel: SearchViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val refresh: () -> Unit = { viewModel.refresh() } + + // auto-refresh every 2 minutes + LaunchedEffect(uiState) { + delay(120_000) + refresh() + } + + val lifecycleOwner = rememberUpdatedLifecycleOwner() + + // обновление при развороте приложения + DisposableEffect(lifecycleOwner) { + val lifecycle = lifecycleOwner.lifecycle + + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> refresh() + else -> Unit + } + } + + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + var teacherName by remember { mutableStateOf(null) } + + LoadingContent( + empty = uiState is SearchUiState.NoData && uiState.isLoading, + loading = uiState.isLoading, + onRefresh = refresh, + verticalArrangement = Arrangement.Top + ) { + TeacherNameSelector( + teacherName, + { + teacherName = it + viewModel.set(teacherName) + }, + Modifier.fillMaxWidth(), + false, + uiState.isLoading + ) + + Spacer(Modifier.height(10.dp)) + + when (uiState) { + is SearchUiState.HasData -> { + Column { + val data = uiState as SearchUiState.HasData + + UpdateInfo(data.lastUpdateAt, data.updateDates) + Spacer(Modifier.height(10.dp)) + SchedulePager(data.teacher) + } + } + + else -> { + if (!uiState.isLoading) { + TextButton(refresh, Modifier.fillMaxSize()) { + Text(stringResource(R.string.reload), textAlign = TextAlign.Center) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/UpdateInfo.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/UpdateInfo.kt similarity index 83% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/UpdateInfo.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/UpdateInfo.kt index 99f6d21..435bcde 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/group/UpdateInfo.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/screen/schedule/UpdateInfo.kt @@ -1,4 +1,4 @@ -package ru.n08i40k.polytechnic.next.ui.main.schedule.group +package ru.n08i40k.polytechnic.next.ui.screen.schedule import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -23,15 +23,8 @@ import ru.n08i40k.polytechnic.next.R import ru.n08i40k.polytechnic.next.UpdateDates import ru.n08i40k.polytechnic.next.ui.widgets.ExpandableCard import ru.n08i40k.polytechnic.next.ui.widgets.ExpandableCardTitle -import java.text.SimpleDateFormat +import ru.n08i40k.polytechnic.next.utils.* import java.util.Date -import java.util.Locale - - -fun Date.toString(format: String, locale: Locale = Locale.getDefault()): String { - val formatter = SimpleDateFormat(format, locale) - return formatter.format(this) -} val expanded = mutableStateOf(false) @@ -60,14 +53,14 @@ fun UpdateInfo( PaskhalkoDialog() Column( - modifier = Modifier + Modifier .fillMaxWidth() .padding(10.dp) .clickable { ++paskhalkoCounter } ) { Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween ) { Text(text = stringResource(R.string.last_local_update)) Text( @@ -78,12 +71,12 @@ fun UpdateInfo( } Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween ) { - Text(text = stringResource(R.string.last_server_cache_update)) + Text(stringResource(R.string.last_server_cache_update)) Text( - text = cacheUpdateDate, + cacheUpdateDate, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace ) @@ -95,7 +88,7 @@ fun UpdateInfo( ) { Text(text = stringResource(R.string.last_server_schedule_update)) Text( - text = scheduleUpdateDate, + scheduleUpdateDate, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace ) diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/GroupSelector.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/GroupSelector.kt deleted file mode 100644 index abd409e..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/GroupSelector.kt +++ /dev/null @@ -1,110 +0,0 @@ -package ru.n08i40k.polytechnic.next.ui.widgets - -import android.content.Context -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Email -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -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.Modifier -import androidx.compose.ui.platform.LocalContext -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.ScheduleGetGroupNames - -@Composable -private fun getTeacherNames(context: Context, onUpdated: (String?) -> Unit): ArrayList { - val groupPlaceholder = stringResource(R.string.loading) - - val groups = remember { arrayListOf(null, groupPlaceholder) } - - LaunchedEffect(groups) { - ScheduleGetGroupNames(context, { - groups.clear() - groups.addAll(it.names) - onUpdated(groups.getOrElse(0) { "TODO" }!!) - }, { - groups.clear() - groups.add(null) - groups.add(context.getString(R.string.failed_to_fetch_group_names)) - onUpdated(groups[1]!!) - }).send() - } - - return groups -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview(showBackground = true) -@Composable -fun GroupSelector( - value: String? = "ИС-214/24", - isError: Boolean = false, - readOnly: Boolean = false, - teacher: Boolean = false, - onValueChange: (String?) -> Unit = {}, -) { - var expanded by remember { mutableStateOf(false) } - - Box( - modifier = Modifier.wrapContentSize() - ) { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { - expanded = !readOnly && !expanded - } - ) { - val groups = getTeacherNames(LocalContext.current, onValueChange) - - TextField( - label = { Text(stringResource(if (teacher) R.string.supervised_group else R.string.group)) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable), - value = value ?: groups.getOrElse(1) { "TODO" }!!, - leadingIcon = { - Icon( - Icons.Filled.Email, - contentDescription = "group" - ) - }, - onValueChange = {}, - isError = isError, - readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } - ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - groups.forEach { - if (it == null) - return@forEach - - DropdownMenuItem( - text = { Text(it) }, - onClick = { - if (groups.isNotEmpty() && groups[0] != null) - onValueChange(it) - expanded = false - } - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/NotificationCard.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/NotificationCard.kt index 08bca4f..2e69b51 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/NotificationCard.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/NotificationCard.kt @@ -73,6 +73,7 @@ fun NotificationCard( val colors = when (level) { Level.WARNING -> { val colorFamily = extendedColorScheme().warning + CardDefaults.cardColors( containerColor = colorFamily.colorContainer, contentColor = colorFamily.onColorContainer diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/OneTapComplete.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/OneTapComplete.kt new file mode 100644 index 0000000..ec6d57a --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/OneTapComplete.kt @@ -0,0 +1,81 @@ +package ru.n08i40k.polytechnic.next.ui.widgets + +import android.util.Base64 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.vk.id.auth.VKIDAuthUiParams +import com.vk.id.onetap.common.OneTapStyle +import com.vk.id.onetap.common.button.style.OneTapButtonCornersStyle +import com.vk.id.onetap.compose.onetap.OneTap +import com.vk.id.onetap.compose.onetap.OneTapTitleScenario +import ru.n08i40k.polytechnic.next.network.request.vkid.VKIDOAuth +import java.nio.charset.Charset +import java.security.MessageDigest +import java.util.UUID + +private data class PKCE( + val codeVerifier: String, + val state: String, + val codeChallenge: String +) { + companion object { + private val ALLOWED_CHARS = ('A'..'Z') + ('a'..'z') + ('0'..'9') + '_' + '-' + + fun create(): PKCE { + val codeVerifier = List(64) { ALLOWED_CHARS.random() }.joinToString("") + + val sha256Digester = MessageDigest.getInstance("SHA-256") + sha256Digester.update(codeVerifier.toByteArray(Charset.forName("ISO_8859_1"))) + + val codeChallenge = Base64.encodeToString( + sha256Digester.digest(), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + + return PKCE(codeVerifier, UUID.randomUUID().toString(), codeChallenge) + } + } +} + +private val PkceSaver = listSaver, String>( + save = { + val value by it + + listOf(value.codeVerifier, value.state, value.codeChallenge) + }, + restore = { mutableStateOf(PKCE(it[0], it[1], it[2])) } +) + +@Composable +fun OneTapComplete(modifier: Modifier = Modifier, onAuth: (String) -> Unit, onFail: () -> Unit) { + val context = LocalContext.current + + var pkce by rememberSaveable(saver = PkceSaver) { mutableStateOf(PKCE.create()) } + val uiParams = VKIDAuthUiParams.Builder().apply { + state = pkce.state + codeChallenge = pkce.codeChallenge + }.build() + + OneTap( + modifier = modifier, + onAuth = { _, _ -> }, + onAuthCode = { authCode, isComplete -> + VKIDOAuth( + VKIDOAuth.RequestDto(authCode.code, pkce.codeVerifier, authCode.deviceId), + { pkce = PKCE.create(); onAuth(it.accessToken) }, + { pkce = PKCE.create() } + ).send(context) + }, + onFail = { _, _ -> pkce = PKCE.create(); onFail() }, + style = OneTapStyle.Dark(cornersStyle = OneTapButtonCornersStyle.Round), + scenario = OneTapTitleScenario.SignIn, + authParams = uiParams + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/TeacherNameSelector.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/TeacherNameSelector.kt deleted file mode 100644 index 62a7c35..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/TeacherNameSelector.kt +++ /dev/null @@ -1,109 +0,0 @@ -package ru.n08i40k.polytechnic.next.ui.widgets - -import android.content.Context -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -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.Modifier -import androidx.compose.ui.platform.LocalContext -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, onUpdated: (String?) -> Unit): ArrayList { - val groupPlaceholder = stringResource(R.string.loading) - - val names = remember { arrayListOf(null, groupPlaceholder) } - - LaunchedEffect(names) { - ScheduleGetTeacherNames(context, { - names.clear() - names.addAll(it.names) - onUpdated(names.getOrElse(0) { "TODO" }!!) - }, { - names.clear() - names.add(null) - names.add(context.getString(R.string.failed_to_fetch_teacher_names)) - onUpdated(names[1]!!) - }).send() - } - - return names -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview(showBackground = true) -@Composable -fun TeacherNameSelector( - value: String? = "Фамилия И.О.", - isError: Boolean = false, - readOnly: Boolean = false, - onValueChange: (String?) -> Unit = {}, -) { - var expanded by remember { mutableStateOf(false) } - - Box( - modifier = Modifier.wrapContentSize() - ) { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { - expanded = !readOnly && !expanded - } - ) { - val names = getTeacherNames(LocalContext.current, onValueChange) - - TextField( - label = { Text(stringResource(R.string.username)) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable), - value = value ?: names.getOrElse(1) { "TODO" }!!, - leadingIcon = { - Icon( - Icons.Filled.Person, - contentDescription = "username" - ) - }, - onValueChange = {}, - isError = isError, - readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } - ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - names.forEach { - if (it == null) - return@forEach - - DropdownMenuItem( - text = { Text(it) }, - onClick = { - if (names.isNotEmpty() && names[0] != null) - onValueChange(it) - expanded = false - } - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayCard.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/DayCard.kt similarity index 53% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayCard.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/DayCard.kt index e8ee421..608c59a 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayCard.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/DayCard.kt @@ -1,7 +1,8 @@ -package ru.n08i40k.polytechnic.next.ui.main.schedule +package ru.n08i40k.polytechnic.next.ui.widgets.schedule import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,6 +12,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -18,6 +20,7 @@ import androidx.compose.runtime.derivedStateOf 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.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -28,10 +31,34 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.datetime.LocalDateTime import ru.n08i40k.polytechnic.next.R -import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository import ru.n08i40k.polytechnic.next.model.Day import ru.n08i40k.polytechnic.next.model.LessonType +import ru.n08i40k.polytechnic.next.repository.schedule.impl.MockScheduleRepository +import ru.n08i40k.polytechnic.next.utils.dateTime +import ru.n08i40k.polytechnic.next.utils.now + +private enum class DayOffset { + YESTERDAY, + TODAY, + TOMORROW, + OTHER +} + +private fun getDayOffset(day: Day): DayOffset { + val now = LocalDateTime.now() + val currentDay = now.date.dayOfWeek + + val dayOfWeek = day.date.dateTime.dayOfWeek + + return when (currentDay.value - dayOfWeek.value) { + -1 -> DayOffset.TOMORROW + 0 -> DayOffset.TODAY + 1 -> DayOffset.YESTERDAY + else -> DayOffset.OTHER + } +} @Composable private fun getCurrentLessonIdx(day: Day?): Flow { @@ -53,9 +80,10 @@ private fun getCurrentLessonIdx(day: Day?): Flow { @Composable fun DayCard( modifier: Modifier = Modifier, - day: Day? = FakeScheduleRepository.exampleGroup.days[0], - distance: Int = 0 + day: Day = MockScheduleRepository.exampleTeacher.days[0] ) { + val offset = remember { getDayOffset(day) } + val defaultCardColors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, @@ -74,104 +102,98 @@ fun DayCard( ) Card( - modifier = modifier, + modifier, colors = CardDefaults.cardColors( - containerColor = - if (distance == 0) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.secondaryContainer + containerColor = when (offset) { + DayOffset.TODAY -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.secondaryContainer + } ), border = BorderStroke(1.dp, MaterialTheme.colorScheme.inverseSurface) ) { - if (day == null) { - Text( - modifier = Modifier.fillMaxWidth(), - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - text = stringResource(R.string.day_null) - ) - return@Card - } Text( - modifier = Modifier.fillMaxWidth(), + day.name, + Modifier.fillMaxWidth(), fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - text = day.name, + style = MaterialTheme.typography.titleLarge ) if (day.street != null) { Text( - modifier = Modifier.fillMaxWidth(), - fontWeight = FontWeight.ExtraLight, + day.street, + Modifier.fillMaxWidth(), textAlign = TextAlign.Center, - text = day.street, ) } - if (distance >= -1 && distance <= 1) { + if (offset != DayOffset.OTHER) { Text( - modifier = Modifier.fillMaxWidth(), + stringResource( + when (offset) { + DayOffset.YESTERDAY -> R.string.yesterday + DayOffset.TODAY -> R.string.today + DayOffset.TOMORROW -> R.string.tomorrow + DayOffset.OTHER -> throw RuntimeException() + } + ), + Modifier.fillMaxWidth(), fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, - text = stringResource( - when (distance) { - -1 -> R.string.yesterday - 0 -> R.string.today - 1 -> R.string.tomorrow - else -> throw RuntimeException() - } - ), ) } - val currentLessonIdx by getCurrentLessonIdx(if (distance == 0) day else null) + val currentLessonIndex by getCurrentLessonIdx(if (offset == DayOffset.TODAY) day else null) .collectAsStateWithLifecycle(0) Column( - modifier = Modifier + Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(0.5.dp) + Arrangement.spacedBy(0.5.dp) ) { if (day.lessons.isEmpty()) { - Text("Can't get schedule!") + Text(stringResource(R.string.empty_day)) return@Column } - for (lessonIdx in day.lessons.indices) { - val lesson = day.lessons[lessonIdx] + for (lessonIndex in day.lessons.indices) { + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.inversePrimary + ) + + val lesson = day.lessons[lessonIndex] val cardColors = when (lesson.type) { - LessonType.DEFAULT -> defaultCardColors - LessonType.ADDITIONAL -> noneCardColors - LessonType.BREAK -> noneCardColors - LessonType.CONSULTATION -> customCardColors + LessonType.DEFAULT -> defaultCardColors + LessonType.ADDITIONAL -> noneCardColors + LessonType.BREAK -> noneCardColors + LessonType.CONSULTATION -> customCardColors LessonType.INDEPENDENT_WORK -> customCardColors - LessonType.EXAM -> examCardColors - LessonType.EXAM_WITH_GRADE -> examCardColors - LessonType.EXAM_DEFAULT -> examCardColors + LessonType.EXAM -> examCardColors + LessonType.EXAM_WITH_GRADE -> examCardColors + LessonType.EXAM_DEFAULT -> examCardColors } - val mutableExpanded = remember { mutableStateOf(false) } + // TODO: Вернуть ExtraInfo + var extraInfo by remember { mutableStateOf(false) } Box( Modifier - .clickable { mutableExpanded.value = true } + .clickable { extraInfo = true } .background(cardColors.containerColor) ) { - val now = lessonIdx == currentLessonIdx + val modifier = + if (lessonIndex == currentLessonIndex) + Modifier.border(BorderStroke(1.dp, MaterialTheme.colorScheme.error)) + else + Modifier - if (lesson.type === LessonType.BREAK) - FreeLessonRow(lesson, cardColors, now) - else - LessonRow(lesson, cardColors, now) + LessonRow(modifier, lesson, cardColors) } - - if (mutableExpanded.value) - LessonExtraInfo(lesson, mutableExpanded) } - } } } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/LessonRow.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/LessonRow.kt new file mode 100644 index 0000000..eb7e0de --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/LessonRow.kt @@ -0,0 +1,197 @@ +package ru.n08i40k.polytechnic.next.ui.widgets.schedule + +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.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.foundation.text.BasicText +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +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.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.model.Lesson +import ru.n08i40k.polytechnic.next.model.LessonType +import ru.n08i40k.polytechnic.next.repository.schedule.impl.MockScheduleRepository +import ru.n08i40k.polytechnic.next.utils.dayMinutes +import ru.n08i40k.polytechnic.next.utils.fmtAsClock + +private enum class TimeFormat { + CLOCK, + DURATION +} + +@Composable +private fun fmtTime(start: Int, end: Int, format: TimeFormat): ArrayList { + return when (format) { + TimeFormat.CLOCK -> arrayListOf(start.fmtAsClock(), end.fmtAsClock()) + TimeFormat.DURATION -> arrayListOf( + "${end - start} ${stringResource(R.string.minutes)}" + ) + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun LessonRow( + modifier: Modifier = Modifier, + lesson: Lesson = MockScheduleRepository.exampleGroup.days[0].lessons[0], + colors: CardColors = CardDefaults.cardColors() +) { + val verticalPadding = when (lesson.type) { + LessonType.BREAK -> 2.5.dp + else -> 5.dp + } + + val timeFormat = when (lesson.type) { + LessonType.BREAK -> TimeFormat.DURATION + else -> TimeFormat.CLOCK + } + + val contentColor = when (lesson.type) { + LessonType.BREAK -> colors.disabledContentColor + else -> colors.contentColor + } + + // магические вычисления)) + val range = lesson.defaultRange + + val rangeSize = + if (range == null) 1 + else (range[1] - range[0] + 1) * 2 + + Box(modifier) { + Row( + Modifier.padding(10.dp, verticalPadding * rangeSize), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + when (range) { + null -> " " + else -> { + if (range[0] == range[1]) + " ${range[0]} " + else + "${range[0]}-${range[1]}" + } + }, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = contentColor + ) + + Spacer(Modifier.width(5.dp)) + + val textMeasurer = rememberTextMeasurer() + val timeWidth = textMeasurer.measure( + text = "00:00 ", + style = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace) + ) + + Column( + Modifier.width(with(LocalDensity.current) { timeWidth.size.width.toDp() }), + horizontalAlignment = Alignment.CenterHorizontally + ) { + var time = fmtTime( + lesson.time.start.dayMinutes, + lesson.time.end.dayMinutes, + timeFormat + ) + + Text(time[0], color = contentColor, fontFamily = FontFamily.Monospace, maxLines = 1) + if (lesson.type != LessonType.BREAK) + Text( + time[1], + color = contentColor, + fontFamily = FontFamily.Monospace, + maxLines = 1 + ) + } + + Spacer(Modifier.width(5.dp)) + + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + // FIXME: Очень странный метод отсеивания, может что-нибудь на замену сделать? + if (lesson.type.value > LessonType.BREAK.value) { + 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 RuntimeException("Unknown lesson type!") + }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = contentColor + ) + } + + Text( + lesson.name ?: stringResource(R.string.lesson_type_break), + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = contentColor + ) + + if (lesson.group != null) { + Text( + lesson.group, + color = contentColor, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + for (subGroup in lesson.subGroups) { + Text( + subGroup.teacher, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Column(Modifier.wrapContentWidth()) { + if (lesson.subGroups.size != 1) { + BasicText("") + + if (lesson.group != null) + BasicText("") + } + + for (subGroup in lesson.subGroups) { + Text( + subGroup.cabinet, + color = contentColor, + maxLines = 1, + fontFamily = FontFamily.Monospace + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayPager.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/SchedulePager.kt similarity index 61% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayPager.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/SchedulePager.kt index 8a3ea80..13996aa 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayPager.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/schedule/SchedulePager.kt @@ -1,4 +1,4 @@ -package ru.n08i40k.polytechnic.next.ui.main.schedule +package ru.n08i40k.polytechnic.next.ui.widgets.schedule import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -16,51 +16,44 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import kotlinx.datetime.LocalDateTime import ru.n08i40k.polytechnic.next.R -import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository import ru.n08i40k.polytechnic.next.model.GroupOrTeacher +import ru.n08i40k.polytechnic.next.repository.schedule.impl.MockScheduleRepository import ru.n08i40k.polytechnic.next.ui.widgets.NotificationCard import ru.n08i40k.polytechnic.next.utils.dateTime import ru.n08i40k.polytechnic.next.utils.now -import java.util.Calendar import java.util.logging.Level import kotlin.math.absoluteValue -private fun isScheduleOutdated(groupOrTeacher: GroupOrTeacher): Boolean { +private fun isScheduleOutdated(schedule: GroupOrTeacher): Boolean { val nowDateTime = LocalDateTime.now() - val lastDay = groupOrTeacher.days.lastOrNull() ?: return true + val lastDay = schedule.days.lastOrNull() ?: return true val lastLesson = lastDay.last ?: return true return nowDateTime > lastLesson.time.end.dateTime } -@Preview +@Preview(showSystemUi = true) @Composable -fun DayPager(groupOrTeacher: GroupOrTeacher = FakeScheduleRepository.exampleGroup) { - val currentDay = (Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 2) - val calendarDay = if (currentDay == -1) 6 else currentDay - +fun SchedulePager(schedule: GroupOrTeacher = MockScheduleRepository.exampleTeacher) { val pagerState = rememberPagerState( - initialPage = calendarDay - .coerceAtMost(groupOrTeacher.days.size - 1), - pageCount = { groupOrTeacher.days.size }) + initialPage = (schedule.currentIdx ?: (schedule.days.size - 1)).coerceAtLeast(0), + pageCount = { schedule.days.size } + ) Column { - if (isScheduleOutdated(groupOrTeacher)) { - NotificationCard( - level = Level.WARNING, - title = stringResource(R.string.outdated_schedule) - ) - } + if (isScheduleOutdated(schedule)) + NotificationCard(Level.WARNING, stringResource(R.string.outdated_schedule)) + HorizontalPager( - state = pagerState, - contentPadding = PaddingValues(horizontal = 7.dp), - verticalAlignment = Alignment.Top, - modifier = Modifier + pagerState, + Modifier .height(600.dp) - .padding(top = 5.dp) + .padding(top = 5.dp), + PaddingValues(horizontal = 7.dp), + verticalAlignment = Alignment.Top ) { page -> DayCard( - modifier = Modifier.graphicsLayer { + Modifier.graphicsLayer { val offset = pagerState.getOffsetDistanceInPages( page.coerceIn(0, pagerState.pageCount - 1) ).absoluteValue @@ -75,8 +68,7 @@ fun DayPager(groupOrTeacher: GroupOrTeacher = FakeScheduleRepository.exampleGrou start = 0.5f, stop = 1f, fraction = 1f - offset.coerceIn(0f, 1f) ) }, - day = groupOrTeacher.days[page], - distance = page - currentDay + schedule.days[page] ) } } diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/GroupSelector.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/GroupSelector.kt new file mode 100644 index 0000000..e5a0954 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/GroupSelector.kt @@ -0,0 +1,60 @@ +package ru.n08i40k.polytechnic.next.ui.widgets.selector + +import android.annotation.SuppressLint +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +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.Modifier +import androidx.compose.ui.platform.LocalContext +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.ScheduleGetGroupNames + +@SuppressLint("MutableCollectionMutableState") +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +fun GroupSelector( + value: String? = "ИС-214/24", + onValueChange: (String?) -> Unit = {}, + modifier: Modifier = Modifier, + isError: Boolean = false, + readOnly: Boolean = false, + supervised: Boolean = false, +) { + ListSelector( + value, + onValueChange, + modifier, + readOnly, + stringResource(if (supervised) R.string.supervised_group else R.string.group), + { Icon(Icons.Filled.Person, stringResource(R.string.cd_user_icon)) }, + isError + ) { + val context = LocalContext.current + var entries by remember { mutableStateOf?>(null) } + + LaunchedEffect(context) { + ScheduleGetGroupNames( + listener = { + entries = it.names + it(entries!!.getOrNull(0)) + }, + errorListener = { + entries = arrayListOf() + it(null) + } + ).send(context) + } + + entries + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/ListSelector.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/ListSelector.kt new file mode 100644 index 0000000..01ec5d1 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/ListSelector.kt @@ -0,0 +1,81 @@ +package ru.n08i40k.polytechnic.next.ui.widgets.selector + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import ru.n08i40k.polytechnic.next.R + +@Composable +private fun DropdownMenuItem(text: String, onClick: () -> Unit) { + DropdownMenuItem({ Text(text) }, onClick) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListSelector( + value: String? = null, + onValueChange: (String) -> Unit = {}, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + title: String, + leadingIcon: @Composable () -> Unit, + isError: Boolean = false, + loader: @Composable ((String?) -> Unit) -> ArrayList?, +) { + var expanded by remember { mutableStateOf(false) } + + Box( + modifier = Modifier.wrapContentSize() + ) { + val variants = loader { it?.apply { onValueChange(it) } } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !readOnly && !expanded && variants?.isNotEmpty() == true + } + ) { + val context = LocalContext.current + + val viewValue = + if (variants == null) stringResource(R.string.loading) + else value ?: variants.getOrElse(0) { context.getString(R.string.failed_to_fetch) } + + TextField( + viewValue, + {}, + modifier.menuAnchor(MenuAnchorType.PrimaryEditable), + readOnly = true, + label = { Text(title) }, + leadingIcon = leadingIcon, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + isError = isError + ) + + ExposedDropdownMenu(expanded, { expanded = false }) { + variants?.forEach { + DropdownMenuItem(it) { + if (variants.isNotEmpty()) + onValueChange(it) + + expanded = false + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/RoleSelector.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/RoleSelector.kt similarity index 98% rename from app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/RoleSelector.kt rename to app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/RoleSelector.kt index d767069..e16474d 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/RoleSelector.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/RoleSelector.kt @@ -1,4 +1,4 @@ -package ru.n08i40k.polytechnic.next.ui.widgets +package ru.n08i40k.polytechnic.next.ui.widgets.selector import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.wrapContentSize diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/TeacherNameSelector.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/TeacherNameSelector.kt new file mode 100644 index 0000000..58e42cb --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/widgets/selector/TeacherNameSelector.kt @@ -0,0 +1,59 @@ +package ru.n08i40k.polytechnic.next.ui.widgets.selector + +import android.annotation.SuppressLint +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +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.Modifier +import androidx.compose.ui.platform.LocalContext +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 + +@SuppressLint("MutableCollectionMutableState") +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +fun TeacherNameSelector( + value: String? = "Фамилия И.О.", + onValueChange: (String) -> Unit = {}, + modifier: Modifier = Modifier, + isError: Boolean = false, + readOnly: Boolean = false, +) { + ListSelector( + value, + onValueChange, + modifier, + readOnly, + stringResource(R.string.teacher_name), + { Icon(Icons.Filled.Person, stringResource(R.string.cd_user_icon)) }, + isError + ) { + val context = LocalContext.current + var entries by remember { mutableStateOf?>(null) } + + LaunchedEffect(context) { + ScheduleGetTeacherNames( + listener = { + entries = it.names + it(entries!!.getOrNull(0)) + }, + errorListener = { + entries = arrayListOf() + it(null) + } + ).send(context) + } + + entries + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Context.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Context.kt new file mode 100644 index 0000000..cc05606 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Context.kt @@ -0,0 +1,11 @@ +package ru.n08i40k.polytechnic.next.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri +import ru.n08i40k.polytechnic.next.Application + +fun Context.openLink(link: String) = + this.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)), null) + +val Context.app get() = this.applicationContext as Application \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Extensions.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Extensions.kt index f243859..8e4005b 100644 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Extensions.kt +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Extensions.kt @@ -1,3 +1,5 @@ +@file:Suppress("unused") + package ru.n08i40k.polytechnic.next.utils import kotlinx.datetime.Clock @@ -5,7 +7,10 @@ import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import java.text.SimpleDateFormat import java.util.Calendar +import java.util.Date +import java.util.Locale infix fun T?.or(data: T): T { if (this == null) @@ -52,4 +57,9 @@ val Instant.dateTime: LocalDateTime fun LocalDateTime.Companion.now(): LocalDateTime { val clock = Clock.System.now() return clock.toLocalDateTime(TimeZone.currentSystemDefault()) +} + +fun Date.toString(format: String, locale: Locale = Locale.getDefault()): String { + val formatter = SimpleDateFormat(format, locale) + return formatter.format(this) } \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Lifecycle.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Lifecycle.kt new file mode 100644 index 0000000..627cf68 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Lifecycle.kt @@ -0,0 +1,12 @@ +package ru.n08i40k.polytechnic.next.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner + +@Composable +fun rememberUpdatedLifecycleOwner(): LifecycleOwner { + val lifecycleOwner = LocalLifecycleOwner.current + return remember { lifecycleOwner } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/MyResult.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/MyResult.kt new file mode 100644 index 0000000..44f0597 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/MyResult.kt @@ -0,0 +1,6 @@ +package ru.n08i40k.polytechnic.next.utils + +sealed interface MyResult { + data class Success(val data: T) : MyResult + data class Failure(val exception: Exception = Exception()) : MyResult +} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Observable.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Observable.kt new file mode 100644 index 0000000..a26c4a5 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/Observable.kt @@ -0,0 +1,103 @@ +package ru.n08i40k.polytechnic.next.utils + +import android.content.Context +import android.util.Log +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import java.lang.ref.WeakReference +import java.util.UUID + +@Suppress("unused") +class Observable { + class Subscriber( + private val _observable: WeakReference>, + val uuid: UUID, + + private val _next: (T) -> Unit, + private val _close: (() -> Unit)? = null, + + val context: Context + ) { + class LifecycleObserver(val subscriber: Subscriber) : + DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + + when (val observable = subscriber.observable) { + null -> subscriber.destroy() + else -> observable.unsubscribe(subscriber.uuid) + } + } + } + + val lifecycleObserver = LifecycleObserver(this) + + val observable get() = _observable.get() + + var invalid = false + + init { + if (context is LifecycleOwner) + context.lifecycle.addObserver(lifecycleObserver) + + Log.d("Subscriber", "New subscriber $uuid!") + } + + fun destroy() { + Log.d("Subscriber", "Destroying subscriber...") + + if (context is LifecycleOwner) + context.lifecycle.removeObserver(lifecycleObserver) + + invalid = true + } + + fun next(value: T) { + if (invalid) + throw IllegalStateException("Subscriber is invalid.") + + if (observable == null) + throw NullPointerException("Observable is null.") + + Log.d("Subscriber", "Invoking $uuid!") + _next(value) + } + + fun close() { + if (invalid) + throw IllegalStateException("Subscriber is invalid.") + + if (observable == null) + throw NullPointerException("Observable is null.") + + Log.d("Subscriber", "Closing $uuid!") + _close?.invoke() + + destroy() + } + } + + private val subscribers: HashMap> = hashMapOf() + + fun next(result: T) { + subscribers.values.forEach { it.next(result) } + } + + fun close() { + subscribers.values.forEach { it.close() } + subscribers.clear() + } + + fun subscribe(context: Context, next: (T) -> Unit, close: (() -> Unit)? = null): Subscriber { + val uuid = UUID.randomUUID() + val subscriber = Subscriber(WeakReference(this), uuid, next, close, context) + + subscribers.put(uuid, subscriber) + + return subscriber + } + + fun unsubscribe(uuid: UUID) { + subscribers.remove(uuid)?.destroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/SingleHook.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/SingleHook.kt new file mode 100644 index 0000000..d6f61c0 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/SingleHook.kt @@ -0,0 +1,24 @@ +package ru.n08i40k.polytechnic.next.utils + +@Suppress("unused") +class SingleHook { + private var _resolved: Boolean = false + private val waiters: ArrayList<(T) -> Unit> = arrayListOf() + + val resolved get() = _resolved + + fun resolve(result: T) { + if (_resolved) + return + + _resolved = true + waiters.forEach { it(result) } + waiters.clear() // for fun :) + } + + infix fun wait(waiter: (T) -> Unit): SingleHook { + waiters.add(waiter) + + return this + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/work/FcmSetTokenWorker.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/work/FcmSetTokenWorker.kt deleted file mode 100644 index 6d108e7..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/work/FcmSetTokenWorker.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ru.n08i40k.polytechnic.next.work - -import android.content.Context -import androidx.work.Worker -import androidx.work.WorkerParameters -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking -import ru.n08i40k.polytechnic.next.PolytechnicApplication -import ru.n08i40k.polytechnic.next.data.MyResult -import ru.n08i40k.polytechnic.next.settings.settingsDataStore - -class FcmSetTokenWorker(context: Context, workerParams: WorkerParameters) : - Worker(context, workerParams) { - override fun doWork(): Result { - val fcmToken = inputData.getString("TOKEN") ?: return Result.failure() - - val accessToken = runBlocking { - applicationContext.settingsDataStore.data.map { it.accessToken }.first() - } - if (accessToken.isEmpty()) - return Result.retry() - - val setResult = runBlocking { - (applicationContext as PolytechnicApplication) - .container - .profileRepository - .setFcmToken(fcmToken) - } - - return when (setResult) { - is MyResult.Success -> Result.success() - is MyResult.Failure -> Result.retry() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/work/FcmUpdateCallbackWorker.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/work/FcmUpdateCallbackWorker.kt deleted file mode 100644 index dd75e79..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/work/FcmUpdateCallbackWorker.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ru.n08i40k.polytechnic.next.work - -import android.content.Context -import androidx.work.Worker -import androidx.work.WorkerParameters -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking -import ru.n08i40k.polytechnic.next.data.MyResult -import ru.n08i40k.polytechnic.next.network.request.fcm.FcmUpdateCallback -import ru.n08i40k.polytechnic.next.network.tryFuture -import ru.n08i40k.polytechnic.next.settings.settingsDataStore - -class FcmUpdateCallbackWorker(context: Context, workerParams: WorkerParameters) : - Worker(context, workerParams) { - override fun doWork(): Result { - val version = inputData.getString("VERSION") ?: return Result.failure() - - val accessToken = runBlocking { - applicationContext.settingsDataStore.data.map { it.accessToken }.first() - } - if (accessToken.isEmpty()) - return Result.retry() - - val result = runBlocking { - tryFuture { - FcmUpdateCallback(this@FcmUpdateCallbackWorker.applicationContext, version, it, it) - } - } - - return when (result) { - is MyResult.Success -> Result.success() - is MyResult.Failure -> Result.retry() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/work/LinkUpdateWorker.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/work/LinkUpdateWorker.kt deleted file mode 100644 index eb7cec5..0000000 --- a/app/src/main/java/ru/n08i40k/polytechnic/next/work/LinkUpdateWorker.kt +++ /dev/null @@ -1,30 +0,0 @@ -package ru.n08i40k.polytechnic.next.work - -import android.content.Context -import androidx.work.Worker -import androidx.work.WorkerParameters -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking -import ru.n08i40k.polytechnic.next.PolytechnicApplication -import ru.n08i40k.polytechnic.next.settings.settingsDataStore - -class LinkUpdateWorker(context: Context, params: WorkerParameters) : - Worker(context, params) { - override fun doWork(): Result { - val accessToken = runBlocking { - applicationContext.settingsDataStore.data.map { it.accessToken }.first() - } - if (accessToken.isEmpty()) - return Result.retry() - - runBlocking { - (applicationContext as PolytechnicApplication) - .container - .scheduleRepository - .getGroup() - } - - return Result.success() - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/worker/UpdateFCMTokenWorker.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/worker/UpdateFCMTokenWorker.kt new file mode 100644 index 0000000..d89cd93 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/worker/UpdateFCMTokenWorker.kt @@ -0,0 +1,77 @@ +package ru.n08i40k.polytechnic.next.worker + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +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.MyResult +import java.time.Duration +import java.util.logging.Logger + +class UpdateFCMTokenWorker(context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + private val logger = Logger.getLogger("FCM") + + override suspend fun doWork(): Result { + val fcmToken = inputData.getString("TOKEN") ?: run { + logger.warning("FCM token is missing in input data.") + return Result.failure() + } + val accessToken = applicationContext.settings.data.map { it.accessToken }.first() + + if (accessToken.isEmpty()) { + logger.warning("Access token is empty. Retrying...") + return Result.retry() + } + + if (applicationContext + .appContainer + .profileRepository + .setFCMToken(fcmToken) is MyResult.Failure + ) { + logger.warning("Failed to set FCM token in the profile repository.") + + return Result.retry() + } + + return Result.success() + } + + companion object { + private const val WORK_TAG = "update-fcm-token" + + fun schedule(context: Context, token: String) { + runBlocking { + context.settings.updateData { + it.toBuilder().setFcmToken(token).build() + } + } + + val workManager = WorkManager.getInstance(context) + workManager.cancelAllWorkByTag(WORK_TAG) + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.LINEAR, Duration.ofMinutes(1)) + .setInputData(workDataOf("TOKEN" to token)) + .addTag(WORK_TAG) + .build() + + workManager.enqueue(request) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/worker/UpdateLinkWorker.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/worker/UpdateLinkWorker.kt new file mode 100644 index 0000000..cd0687f --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/worker/UpdateLinkWorker.kt @@ -0,0 +1,58 @@ +package ru.n08i40k.polytechnic.next.worker + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import ru.n08i40k.polytechnic.next.app.appContainer +import ru.n08i40k.polytechnic.next.settings.settings +import java.util.concurrent.TimeUnit +import java.util.logging.Logger + +class UpdateLinkWorker(context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + private val logger = Logger.getLogger("Link") + + override suspend fun doWork(): Result { + val accessToken = applicationContext.settings.data.map { it.accessToken }.first() + + if (accessToken.isEmpty()) { + logger.warning("Access token is empty. Retrying...") + return Result.retry() + } + + applicationContext + .appContainer + .scheduleRepository + .getGroup() + + return Result.success() + } + + companion object { + private const val WORK_TAG = "schedule-update" + + fun schedule(context: Context) { + val workManager = WorkManager.getInstance(context) + workManager.cancelAllWorkByTag(WORK_TAG) + + val remoteConfig = context.appContainer.remoteConfig + val updateDelay = remoteConfig.getLong("linkUpdateDelay") + + if (updateDelay == 0L) + return + + val workRequest = PeriodicWorkRequest.Builder( + UpdateLinkWorker::class.java, + updateDelay.coerceAtLeast(15), TimeUnit.MINUTES + ) + .addTag(WORK_TAG) + .build() + + workManager.enqueue(workRequest) + } + } +} \ No newline at end of file diff --git a/app/src/main/proto/settings.proto b/app/src/main/proto/settings.proto index d1d4bf7..81ae801 100644 --- a/app/src/main/proto/settings.proto +++ b/app/src/main/proto/settings.proto @@ -17,7 +17,12 @@ message Settings { string user_id = 1; string access_token = 2; string group = 3; + map cache_storage = 4; UpdateDates update_dates = 5; + string version = 6; + string suppressed_version = 7; + + string fcm_token = 8; } \ No newline at end of file diff --git a/app/src/main/res/drawable-mdpi/paskhalko.jpg b/app/src/main/res/drawable/paskhalko.jpg similarity index 100% rename from app/src/main/res/drawable-mdpi/paskhalko.jpg rename to app/src/main/res/drawable/paskhalko.jpg diff --git a/app/src/main/res/drawable/telegram.xml b/app/src/main/res/drawable/telegram.xml deleted file mode 100644 index c773fa8..0000000 --- a/app/src/main/res/drawable/telegram.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d287c2a..4358c2d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,90 +1,92 @@ - - Политехникум - Имя пользователя - Пароль - Продолжить - Авторизация + Политехникум+ Регистрация - Не зарегистрированы? - Уже зарегистрированы? - Перезагрузить - Преподаватель - Преподаватели - Длительность - ч. - мин. - Перемена - Расписание - Профиль + Авторизация + Перемена + Перерыв + в спорт-зале + в %1$s каб. Студент Преподаватель Администратор - Группа Роль - На этот день расписания ещё нет! + Загрузка… + Не удалось загрузить данные. + Имя пользователя + Курируемая группа + Группа + Пусто + Иконка пользователя + Пароль + Уже зарегистрированы? + Продолжить + Не зарегистрированы? + Неправильный логин или пароль! + Не удалось отправить запрос! Попробуйте позже. + Нету подключения к интернету! + Произошла неивестная ошибка! Попробуйте позже. + Пользоватесь с таким именем уже зарегистрирован! + Группа с таким названием не существует! + Действия топ-бара + Профиль + Замена + Преподаватели + Расписание + Расписание + Перезагрузить + Иконка профиля + Иконка пароля + Иконка роли + Иконка группы + Выйти с аккаунта + Сменить группу Старый пароль Новый пароль - Загрузка… Сменить пароль - Сменить имя пользователя - Сменить группу - Выйти с аккаунта - Кабинеты - Последнее локальное обновление - Последнее обновление кеша - Последнее обновление расписания - Дополнительная информация - Заменитель - байт - Удалить всё - Загрузить новое расписание - Обновления расписания - Информирует об обновлении расписания - Расписание обновлено! - Расписание было обновлено Администратором. - Расписание было обновлено на сайте политехникума. - Скачать обновление - Телеграм канал - Обновление приложения - Информирует о выходе новой версии этого приложения - Вышла версия %1$s! - Нажмите что бы загрузить обновление. - Текущая пара - Отображает текущую пару или перемену в уведомлении - Загрузка расписания… - Это уведомление обновится в течении нескольких секунд! - До конца %1$d ч. %2$d мин. - %1$s\n| Далее в %2$s - %3$s - Конец пар - Пары закончились! - Ура, можно идти домой! Наверное :( - каб. - в %1$s каб. - в спорт-зале - Пары ещё не начались - До начала пар %1$d ч. %2$d мин. + Сменить имя Вы просматриваете устаревшее расписание! - Некорректное имя пользователя или пароль! - Неудалось отправить запрос, попробуйте позже! - Пожалуйста обновите приложение! - Произошла неизвестная ошибка! Попробуйте позже. - Не удалось получить список названий групп! - Пользователь с таким именем уже зарегистрирован! - Группа с таким названием не существует! - Нет подключения к интернету! - Сегодня Вчера + Сегодня Завтра - Преподаватели - ФИО преподавателя - Преподаватель не выбран или вы допустили ошибку в его ФИО. - Не удалось получить список ФИО преподавателей! + В этот день нету пар :) + мин Консультация Самостоятельная работа ЗАЧЁТ ЗАЧЁТ С ОЦЕНКОЙ ЭКЗАМЕН - Курируемая группа - Преподаватель + Перемена + Иконка VK + К этому аккаунту VK уже привязан другой аккаунт. + Иконка ручной регистрации + Вручную + или + Кнопка возврата в меню выбора способа регистрации + К этой учетной записи VK не привязано ни одного аккаунта. + Недействительный токен доступа VK! + Пожалуйста, используйте другую роль. + По имени пользователя + Поддержка версий ниже %1$s прекращена! + Последнее обновление расписания + ФИО преподавателя + Желаете ли вы обновиться до последней версии? + Вышла новая версия приложения! + НЕТ + ВЫЙТИ + ОБНОВИТЬ + ЗАГЛУШИТЬ + Дополнительная информация + Последнее локальное обновление + Последнее обновление кеша + Скачать обновление + Телеграм канал + Расписание обновлено! + Расписание обновлено администратором. + Расписание обновлено на сайте политехникума. + Вышла версия %1$s! + Нажмите, чтобы загрузить новую версию. + Обновления расписания + Уведомления об обновлении расписания + Обновления приложения + Уведомления о выходе новой версии этого приложения \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 028dd59..f8c6127 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,4 +1,10 @@ + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 #FFFFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 24b4c3d..a4e34a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,90 +1,92 @@ - Polytechnic - - Username - Password - Proceed - Sign In + Polytechnic+ Sign Up - Not registered? - Already registered? - Reload - Teacher - Teachers - Duration - h. - min. - Break - Schedule - Profile + Sign In + Break + Break + in gym + in %1$s cab. Student Teacher Administrator - Group Role - There is no schedule for this day yet! + Loading… + Failed to fetch data. + Username + Supervised group + Group + Empty + User icon + Password + Already registered? + Proceed + Not registered? + Invalid credentials! + Failed to send request! Try again later. + No internet connection! + An unknown error has occurred! Please try again later. + A user with this name already registered! + A group with this name does not exist! + Top app bar actions + Profile + Replacer + Teachers + Schedule + Schedule + Reload + Profile icon + Password icon + Role icon + Group icon + Sign Out + Change group Old password New password Change password Change username - Loading… - Change group - Sign out - Cabinets - Last local update - Last server cache update - Last server schedule update - Additional information - Replacer - bytes - Clear - Set new - Schedule update - Inform when schedule has been updated - Schedule has been updated! - Schedule was updated by Administrator. - Schedule was updated on polytechnic website. - Download update - Telegram channel - Application update - Inform about new version of this app has been released - Version %1$s released! - Click to download new version. - Current lesson - View current lesson and breaks in notification - Loading schedule… - This notification will be updated in several seconds! - To end %1$d h. %2$d min. - %1$s\n| After in %2$s - %3$s - Lessons end - Lessons finished! - TODO - cab. - in %1$s cab. - in gym - Lessons haven\'t started yet - %1$d h. %2$d min. before lessons start - You are viewing an outdated schedule! - Invalid credentials! - Failed to send request, try again later! - Please update the application! - An unknown error has occurred! Please try again later. - Failed to get list of group names! - A user with this name is already registered! - A group with this name does not exist! - No internet connection! - Today + You are viewing outdated schedule! Yesterday + Today Tomorrow - Teachers - Teacher name - Teacher not selected or you made mistake in his name. - Failed to fetch teacher names! + This day doesn\'t contains any lessons :) + min Consultation Independent work EXAM* EXAM* WITH GRADE EXAM - Supervised group - Teacher + Break + VK icon + Manual + or + Back icon + Another account is already linked to this VK account. + Manual icon + Please, use another role. + Manual + There are no accounts linked to this VK account. + Invalid VK access token! + Support for versions below %1$s has been discontinued! + Would you like to update to the latest version? + A new version of the application has been released! + NO + EXIT + UPDATE + SUPPRESS + Additional information + Last local update + Last server cache update + Last server schedule update + Teacher name + Download update + Telegram channel + Schedule has been updated! + Schedule was updated by Administrator. + Schedule was updated on polytechnic website. + Version %1$s released! + Click to download a new version. + Schedule update + Inform when schedule has been updated + Application update + Inform about a new version of this app has been released \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a58e19b..b2ed9bf 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ -