From 4c75b87656bfc58721f73f86c7e3ed06c2e3fb44 Mon Sep 17 00:00:00 2001 From: n08i40k Date: Sun, 15 Sep 2024 14:39:30 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 15 + app/.gitignore | 1 + app/build.gradle.kts | 143 +++++ app/google-services.json | 29 + app/proguard-rules.pro | 21 + .../next/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 29 + .../n08i40k/polytechnic/next/MainViewModel.kt | 9 + .../next/PolytechnicApplication.kt | 12 + .../polytechnic/next/data/AppContainer.kt | 51 ++ .../n08i40k/polytechnic/next/data/MyResult.kt | 12 + .../next/data/schedule/ScheduleRepository.kt | 8 + .../schedule/impl/FakeScheduleRepository.kt | 166 ++++++ .../schedule/impl/RemoteScheduleRepository.kt | 44 ++ .../next/data/users/ProfileRepository.kt | 8 + .../data/users/impl/FakeProfileRepository.kt | 30 + .../users/impl/RemoteProfileRepository.kt | 29 + .../n08i40k/polytechnic/next/model/Group.kt | 43 ++ .../n08i40k/polytechnic/next/model/Profile.kt | 34 ++ .../polytechnic/next/network/NetworkValues.kt | 5 + .../polytechnic/next/network/Request.kt | 83 +++ .../polytechnic/next/network/Response.kt | 7 + .../next/network/data/AuthorizedRequest.kt | 51 ++ .../next/network/data/auth/ChangePassword.kt | 25 + .../data/auth/ChangePasswordRequestData.kt | 6 + .../next/network/data/auth/Login.kt | 24 + .../network/data/auth/LoginRequestData.kt | 6 + .../network/data/auth/LoginResponseData.kt | 6 + .../next/network/data/auth/Register.kt | 24 + .../network/data/auth/RegisterRequestData.kt | 12 + .../network/data/auth/RegisterResponseData.kt | 6 + .../next/network/data/profile/ChangeGroup.kt | 24 + .../data/profile/ChangeGroupRequestData.kt | 6 + .../network/data/profile/ChangeUsername.kt | 24 + .../data/profile/ChangeUsernameRequestData.kt | 6 + .../next/network/data/profile/UsersMe.kt | 19 + .../next/network/data/schedule/ScheduleGet.kt | 22 + .../data/schedule/ScheduleGetGroupNames.kt | 16 + .../ScheduleGetGroupNamesResponseData.kt | 8 + .../data/schedule/ScheduleGetRequestData.kt | 6 + .../data/schedule/ScheduleGetResponse.kt | 12 + .../next/settings/SettingsSerializer.kt | 30 + .../polytechnic/next/ui/LoadingContent.kt | 50 ++ .../polytechnic/next/ui/MainActivity.kt | 38 ++ .../polytechnic/next/ui/PolytechnicApp.kt | 33 ++ .../polytechnic/next/ui/auth/AuthScreen.kt | 525 ++++++++++++++++++ .../polytechnic/next/ui/main/Constants.kt | 19 + .../polytechnic/next/ui/main/MainScreen.kt | 139 +++++ .../next/ui/main/profile/ChangeGroupDialog.kt | 189 +++++++ .../ui/main/profile/ChangePasswordDialog.kt | 136 +++++ .../ui/main/profile/ChangeUsernameDialog.kt | 113 ++++ .../next/ui/main/profile/ProfileCard.kt | 183 ++++++ .../next/ui/main/profile/ProfileScreen.kt | 50 ++ .../next/ui/main/schedule/DayCard.kt | 179 ++++++ .../next/ui/main/schedule/DayPager.kt | 52 ++ .../next/ui/main/schedule/LessonView.kt | 219 ++++++++ .../next/ui/main/schedule/ScheduleScreen.kt | 47 ++ .../next/ui/model/ProfileViewModel.kt | 92 +++ .../next/ui/model/ScheduleViewModel.kt | 70 +++ .../polytechnic/next/ui/theme/Color.kt | 226 ++++++++ .../polytechnic/next/ui/theme/Theme.kt | 278 ++++++++++ .../n08i40k/polytechnic/next/ui/theme/Type.kt | 5 + .../next/utils/EnumAsIntSerializer.kt | 27 + .../next/utils/EnumAsStringSerializer.kt | 26 + .../polytechnic/next/utils/ErrorMessage.kt | 3 + app/src/main/proto/settings.proto | 10 + app/src/main/res/drawable/ic_launcher.xml | 16 + .../main/res/drawable/ic_launcher_round.xml | 16 + app/src/main/res/drawable/logo.xml | 12 + app/src/main/res/raw/ssl.pem | 27 + app/src/main/res/resources.properties | 1 + app/src/main/res/values-ru/strings.xml | 33 ++ app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 33 ++ app/src/main/res/values/themes.xml | 5 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../polytechnic/next/ExampleUnitTest.kt | 17 + build.gradle.kts | 16 + gradle.properties | 23 + gradle/libs.versions.toml | 56 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 185 ++++++ gradlew.bat | 89 +++ settings.gradle.kts | 24 + 86 files changed, 4446 insertions(+) create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/google-services.json create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/ru/n08i40k/polytechnic/next/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/MainViewModel.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/PolytechnicApplication.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/data/AppContainer.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/data/MyResult.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/ScheduleRepository.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/FakeScheduleRepository.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/RemoteScheduleRepository.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/data/users/ProfileRepository.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/FakeProfileRepository.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/RemoteProfileRepository.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/model/Group.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/model/Profile.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/NetworkValues.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/Request.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/Response.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/AuthorizedRequest.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/ChangePassword.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/ChangePasswordRequestData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/Login.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/LoginRequestData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/LoginResponseData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/Register.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/RegisterRequestData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/RegisterResponseData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeGroup.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeGroupRequestData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeUsername.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeUsernameRequestData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/UsersMe.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGet.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetGroupNames.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetGroupNamesResponseData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetRequestData.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetResponse.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/settings/SettingsSerializer.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/LoadingContent.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/MainActivity.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/PolytechnicApp.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/AuthScreen.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/Constants.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/MainScreen.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeGroupDialog.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangePasswordDialog.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeUsernameDialog.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileCard.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileScreen.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayCard.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayPager.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/LessonView.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/ScheduleScreen.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ProfileViewModel.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ScheduleViewModel.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Color.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Theme.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Type.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/utils/EnumAsIntSerializer.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/utils/EnumAsStringSerializer.kt create mode 100644 app/src/main/java/ru/n08i40k/polytechnic/next/utils/ErrorMessage.kt create mode 100644 app/src/main/proto/settings.proto create mode 100644 app/src/main/res/drawable/ic_launcher.xml create mode 100644 app/src/main/res/drawable/ic_launcher_round.xml create mode 100644 app/src/main/res/drawable/logo.xml create mode 100644 app/src/main/res/raw/ssl.pem create mode 100644 app/src/main/res/resources.properties create mode 100644 app/src/main/res/values-ru/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/ru/n08i40k/polytechnic/next/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..2101c07 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,143 @@ +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) + + kotlin("plugin.serialization") version "2.0.20" + + id("com.google.devtools.ksp") + + id("com.google.protobuf") version "0.9.4" + + id("com.google.gms.google-services") + alias(libs.plugins.google.firebase.crashlytics) + + id("com.google.dagger.hilt.android") +} + +android { + namespace = "ru.n08i40k.polytechnic.next" + compileSdk = 35 + + androidResources { + @Suppress("UnstableApiUsage") + generateLocaleConfig = true + } + + defaultConfig { + applicationId = "ru.n08i40k.polytechnic.next" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + sourceSets { + getByName("main") { + proto { + srcDir("src/main/proto") + } + } + getByName("test") { + proto { + srcDir("src/test/proto") + } + } + getByName("androidTest") { + proto { + srcDir("src/androidTest/proto") + } + } + } +} + +dependencies { + implementation(libs.hilt.android) + ksp(libs.hilt.android.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.analytics) + + implementation(libs.androidx.datastore) + implementation(libs.protobuf.lite) + + implementation(libs.accompanist.swiperefresh) + + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.volley) + implementation(libs.androidx.navigation.compose) + implementation(libs.firebase.crashlytics) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.6.1" + } + + plugins { + id("javalite") { + artifact = "com.google.protobuf:protoc-gen-javalite:3.0.0" + } + } + + generateProtoTasks { + all().forEach { task -> + task.plugins { + id("javalite") { } + } + } + } +} diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..19b064c --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "946974192625", + "project_id": "polytecnicnext", + "storage_bucket": "polytecnicnext.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:946974192625:android:f7981fae384940a882ca50", + "android_client_info": { + "package_name": "ru.n08i40k.polytechnic.next" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDdE7os5ZLZSzwh6bY9ti-xAq6CXTJ7RBw" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ 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 new file mode 100644 index 0000000..b4f211d --- /dev/null +++ b/app/src/androidTest/java/ru/n08i40k/polytechnic/next/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package ru.n08i40k.polytechnic.next + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("ru.n08i40k.polytecnic.next", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..90fd030 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 0000000..4700123 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/MainViewModel.kt @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..8f0def4 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/PolytechnicApplication.kt @@ -0,0 +1,12 @@ +package ru.n08i40k.polytechnic.next + +import android.app.Application +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 +} \ 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 new file mode 100644 index 0000000..83f38f0 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/data/AppContainer.kt @@ -0,0 +1,51 @@ +package ru.n08i40k.polytechnic.next.data + +import android.app.Application +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.components.SingletonComponent +import ru.n08i40k.polytechnic.next.PolytechnicApplication +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 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 javax.inject.Singleton + +interface AppContainer { + val scheduleRepository: ScheduleRepository + val profileRepository: ProfileRepository +} + +class MockAppContainer : AppContainer { + override val scheduleRepository: ScheduleRepository by lazy { FakeScheduleRepository() } + override val profileRepository: ProfileRepository by lazy { FakeProfileRepository() } +} + +class RemoteAppContainer(private val applicationContext: Context) : AppContainer { + override val scheduleRepository: ScheduleRepository by lazy { + RemoteScheduleRepository( + applicationContext + ) + } + override val profileRepository: ProfileRepository by lazy { + RemoteProfileRepository( + applicationContext + ) + } +} + +@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 new file mode 100644 index 0000000..aad1ad8 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/data/MyResult.kt @@ -0,0 +1,12 @@ +package ru.n08i40k.polytechnic.next.data + +import java.lang.Exception + +sealed interface MyResult { + data class Success(val data: T) : MyResult + data class Failure(val exception: Exception) : MyResult +} + +fun MyResult.successOr(fallback: T): T { + return (this as? MyResult.Success)?.data ?: fallback +} \ 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/data/schedule/ScheduleRepository.kt new file mode 100644 index 0000000..d7198ac --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/ScheduleRepository.kt @@ -0,0 +1,8 @@ +package ru.n08i40k.polytechnic.next.data.schedule + +import ru.n08i40k.polytechnic.next.model.Group +import ru.n08i40k.polytechnic.next.data.MyResult + +interface ScheduleRepository { + suspend fun getGroup(): MyResult +} \ No newline at end of file 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/data/schedule/impl/FakeScheduleRepository.kt new file mode 100644 index 0000000..bee3e44 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/FakeScheduleRepository.kt @@ -0,0 +1,166 @@ +package ru.n08i40k.polytechnic.next.data.schedule.impl + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import ru.n08i40k.polytechnic.next.model.Day +import ru.n08i40k.polytechnic.next.model.Group +import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository +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.data.MyResult; + +class FakeScheduleRepository : ScheduleRepository { + companion object { + val exampleGroup = Group( + name = "ИС-214/23", days = arrayListOf( + Day( + name = "Понедельник", + nonNullIndices = arrayListOf(0, 1, 2, 3, 4, 5), + defaultIndices = arrayListOf(2, 3, 4, 5), + customIndices = arrayListOf(0, 1), + lessons = arrayListOf( + Lesson( + type = LessonType.CUSTOM, + defaultIndex = -1, + name = "Линейка", + time = LessonTime(510, 520), + cabinets = arrayListOf(), + teacherNames = arrayListOf(), + ), + Lesson( + type = LessonType.CUSTOM, + defaultIndex = -1, + name = "Разговор о важном", + time = LessonTime(525, 555), + cabinets = arrayListOf(), + teacherNames = arrayListOf(), + ), + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 1, + name = "Элементы высшей математики", + time = LessonTime(565, 645), + cabinets = arrayListOf("31"), + teacherNames = arrayListOf("Цацаева Т.Н."), + ), + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 2, + name = "Операционные системы и среды", + time = LessonTime(655, 735), + cabinets = arrayListOf("42"), + teacherNames = arrayListOf("Сергачева А.О."), + ), + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 3, + name = "Физическая культура", + time = LessonTime(755, 835), + cabinets = arrayListOf("c/3"), + teacherNames = arrayListOf("Васюнин В.Г."), + ), + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 4, + name = "МДК.05.01 Проектирование и дизайн информационных систем", + time = LessonTime(845, 925), + cabinets = arrayListOf("43"), + teacherNames = arrayListOf("Ивашова А.Н."), + ), + null, + null, + ) + ), Day( + name = "Вторник", + nonNullIndices = arrayListOf(0, 1, 2), + defaultIndices = arrayListOf(0, 1, 2), + customIndices = arrayListOf(), + lessons = arrayListOf( + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 1, + name = "Стандартизация, сертификация и техническое документоведение", + time = LessonTime(525, 605), + cabinets = arrayListOf("42"), + teacherNames = arrayListOf("Сергачева А.О."), + ), + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 2, + name = "Элементы высшей математики", + time = LessonTime(620, 700), + cabinets = arrayListOf("31"), + teacherNames = arrayListOf("Цацаева Т.Н."), + ), + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 3, + name = "Основы проектирования баз данных", + time = LessonTime(720, 800), + cabinets = arrayListOf("21"), + teacherNames = arrayListOf("Чинарева Е.А."), + ), + null, + null, + null, + null, + null, + ) + ), Day( + name = "Среда", + nonNullIndices = arrayListOf(0, 1, 2), + defaultIndices = arrayListOf(0, 1, 2), + customIndices = arrayListOf(), + lessons = arrayListOf( + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 1, + name = "Операционные системы и среды", + time = LessonTime(525, 605), + cabinets = arrayListOf("42"), + teacherNames = arrayListOf("Сергачева А.О."), + ), + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 2, + name = "Элементы высшей математики", + time = LessonTime(620, 700), + cabinets = arrayListOf("31"), + teacherNames = arrayListOf("Цацаева Т.Н."), + ), + Lesson( + type = LessonType.DEFAULT, + defaultIndex = 3, + name = "МДК.05.01 Проектирование и дизайн информационных систем", + time = LessonTime(720, 800), + cabinets = arrayListOf("43"), + teacherNames = arrayListOf("Ивашова А.Н."), + ), + null, + null, + null, + null, + null, + ) + ) + ) + ) + } + + private val group = MutableStateFlow(exampleGroup) + + 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!!) + } + } +} \ 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/data/schedule/impl/RemoteScheduleRepository.kt new file mode 100644 index 0000000..b5c6d80 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/data/schedule/impl/RemoteScheduleRepository.kt @@ -0,0 +1,44 @@ +package ru.n08i40k.polytechnic.next.data.schedule.impl + +import android.content.Context +import com.android.volley.toolbox.RequestFuture +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import ru.n08i40k.polytechnic.next.data.MyResult +import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository +import ru.n08i40k.polytechnic.next.model.Group +import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetRequest +import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetRequestData +import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetResponse +import ru.n08i40k.polytechnic.next.settings.settingsDataStore +import java.lang.Thread.sleep + +class RemoteScheduleRepository(private val context: Context) : ScheduleRepository { + override suspend fun getGroup(): MyResult { + return withContext(Dispatchers.IO) { + val groupName = runBlocking { + context.settingsDataStore.data.map { settings -> settings.group }.first() + } + + if (groupName.isEmpty()) + return@withContext MyResult.Failure(RuntimeException("No group name provided!")) + + val responseFuture = RequestFuture.newFuture() + ScheduleGetRequest( + ScheduleGetRequestData(groupName), + context, + responseFuture, + responseFuture + ).send() + + try { + MyResult.Success(responseFuture.get().group) + } catch (exception: Exception) { + MyResult.Failure(exception) + } + } + } +} \ 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 new file mode 100644 index 0000000..00e6c73 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/ProfileRepository.kt @@ -0,0 +1,8 @@ +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 +} \ 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 new file mode 100644 index 0000000..fbfe27a --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/FakeProfileRepository.kt @@ -0,0 +1,30 @@ +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 +import java.lang.Exception + +class FakeProfileRepository : ProfileRepository { + private var counter = 0 + + companion object { + val exampleProfile = + Profile("66db32d24030a07e02d974c5", "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) + } + } +} \ 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 new file mode 100644 index 0000000..3f5eab4 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/data/users/impl/RemoteProfileRepository.kt @@ -0,0 +1,29 @@ +package ru.n08i40k.polytechnic.next.data.users.impl + +import android.content.Context +import com.android.volley.toolbox.RequestFuture +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.data.profile.UsersMeRequest + +class RemoteProfileRepository(private val context: Context) : ProfileRepository { + override suspend fun getProfile(): MyResult { + return withContext(Dispatchers.IO) { + val responseFuture = RequestFuture.newFuture() + UsersMeRequest( + context, + responseFuture, + responseFuture + ).send() + + try { + MyResult.Success(responseFuture.get()) + } catch (exception: Exception) { + MyResult.Failure(exception) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/model/Group.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Group.kt new file mode 100644 index 0000000..d2a36b1 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Group.kt @@ -0,0 +1,43 @@ +package ru.n08i40k.polytechnic.next.model + +import kotlinx.serialization.Serializable +import ru.n08i40k.polytechnic.next.utils.EnumAsIntSerializer + +@Serializable +data class LessonTime(val start: Int, val end: Int) + +private class LessonTypeIntSerializer : EnumAsIntSerializer( + "LessonType", + { it.value }, + { v -> LessonType.entries.first { it.value == v } } +) + +@Serializable(with = LessonTypeIntSerializer::class) +enum class LessonType(val value: Int) { + DEFAULT(0), CUSTOM(1) +} + +@Serializable +data class Lesson( + val type: LessonType, + val defaultIndex: Int, + val name: String, + val time: LessonTime?, + val cabinets: ArrayList, + val teacherNames: ArrayList +) + +@Serializable +class Day( + val name: String, + val nonNullIndices: ArrayList, + val defaultIndices: ArrayList, + val customIndices: ArrayList, + val lessons: ArrayList +) + +@Serializable +class Group( + val name: String, + val days: ArrayList +) \ 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 new file mode 100644 index 0000000..d2b5928 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/model/Profile.kt @@ -0,0 +1,34 @@ +package ru.n08i40k.polytechnic.next.model + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.serialization.Serializable +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.utils.EnumAsStringSerializer + +private class UserRoleStringSerializer : EnumAsStringSerializer( + "UserRole", + { it.value }, + { v -> UserRole.entries.first { it.value == v } } +) + +@Serializable(with = UserRoleStringSerializer::class) +enum class UserRole(val value: String, val icon: ImageVector, @StringRes val stringId: Int) { + STUDENT("STUDENT", Icons.Filled.Face, R.string.role_student), + TEACHER("TEACHER", Icons.Filled.Person, R.string.role_teacher), + ADMIN("ADMIN", Icons.Filled.Settings, R.string.role_admin) +} + +val AcceptableUserRoles = listOf(UserRole.STUDENT, UserRole.TEACHER) + +@Serializable +data class Profile( + val id: String, + val username: String, + val group: String, + val role: UserRole +) 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 new file mode 100644 index 0000000..d9f94e5 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/NetworkValues.kt @@ -0,0 +1,5 @@ +package ru.n08i40k.polytechnic.next.network + +object NetworkValues { + const val API_HOST = "https://192.168.0.103:5050/api/v1/" +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/Request.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/Request.kt new file mode 100644 index 0000000..7f6ce20 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/Request.kt @@ -0,0 +1,83 @@ +package ru.n08i40k.polytechnic.next.network + +import android.annotation.SuppressLint +import android.content.Context +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.toolbox.HurlStack +import com.android.volley.toolbox.StringRequest +import com.android.volley.toolbox.Volley +import java.security.cert.X509Certificate +import java.util.logging.Logger +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + + +class NetworkConnection(ctx: Context) { + companion object { + @Volatile + private var INSTANCE: NetworkConnection? = null + + fun getInstance(ctx: Context) = INSTANCE ?: synchronized(this) { + INSTANCE ?: NetworkConnection(ctx).also { INSTANCE = it } + } + } + + private val sslSocketFactory: SSLSocketFactory by lazy { + val trustAllCerts = + arrayOf(@SuppressLint("CustomX509TrustManager") object : + X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted( + chain: Array, authType: String + ) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted( + chain: Array, authType: String + ) { + } + + override fun getAcceptedIssuers(): Array { + return arrayOf() + } + }) + + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, null) + + sslContext.socketFactory + } + + private val requestQueue: RequestQueue by lazy { + Volley.newRequestQueue(ctx.applicationContext, HurlStack(null, sslSocketFactory)) + } + + fun addToRequestQueue(req: Request) { + requestQueue.add(req) + } +} + +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) { + fun send() { + Logger.getLogger("RequestBase").info("Sending request to $url") + NetworkConnection.getInstance(context).addToRequestQueue(this) + } + + override fun getHeaders(): MutableMap { + val headers = mutableMapOf() + headers["Content-Type"] = "application/json; charset=utf-8" + + return headers + } +} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/Response.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/Response.kt new file mode 100644 index 0000000..f8c5d3a --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/Response.kt @@ -0,0 +1,7 @@ +package ru.n08i40k.polytechnic.next.network + +class ResponseBase { + fun handleResponse(response: String?) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/AuthorizedRequest.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/AuthorizedRequest.kt new file mode 100644 index 0000000..d098499 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/AuthorizedRequest.kt @@ -0,0 +1,51 @@ +package ru.n08i40k.polytechnic.next.network.data + +import android.content.Context +import com.android.volley.AuthFailureError +import com.android.volley.Response +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import ru.n08i40k.polytechnic.next.network.RequestBase +import ru.n08i40k.polytechnic.next.settings.settingsDataStore +import ru.n08i40k.polytechnic.next.ui.model.profileViewModel + +open class AuthorizedRequest( + context: Context, + method: Int, + url: String?, + listener: Response.Listener, + errorListener: Response.ErrorListener?, + 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() + } + } + context.profileViewModel!!.onUnauthorized() + } + + errorListener?.onErrorResponse(it) + }) { + override fun getHeaders(): MutableMap { + val accessToken = runBlocking { + context.settingsDataStore.data.map { settings -> settings.accessToken }.first() + } + + if (accessToken.isEmpty()) + context.profileViewModel!!.onUnauthorized() + + val headers = super.getHeaders() + headers["Authorization"] = "Bearer $accessToken" + + return headers + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/ChangePassword.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/ChangePassword.kt new file mode 100644 index 0000000..5780d97 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/ChangePassword.kt @@ -0,0 +1,25 @@ +package ru.n08i40k.polytechnic.next.network.data.auth + +import android.content.Context +import com.android.volley.Response +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest + +class ChangePasswordRequest( + private val data: ChangePasswordRequestData, + context: Context, + listener: Response.Listener, + errorListener: Response.ErrorListener? +) : AuthorizedRequest( + context, + Method.POST, + "auth/change-password", + Response.Listener { listener.onResponse(null) }, + errorListener, + canBeUnauthorized = true +) { + 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/data/auth/ChangePasswordRequestData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/ChangePasswordRequestData.kt new file mode 100644 index 0000000..82cd4d4 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/ChangePasswordRequestData.kt @@ -0,0 +1,6 @@ +package ru.n08i40k.polytechnic.next.network.data.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class ChangePasswordRequestData(val oldPassword: String, val newPassword: String) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/Login.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/Login.kt new file mode 100644 index 0000000..c0539bd --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/Login.kt @@ -0,0 +1,24 @@ +package ru.n08i40k.polytechnic.next.network.data.auth + +import android.content.Context +import com.android.volley.Response +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.network.RequestBase + +class LoginRequest( + private val data: LoginRequestData, + context: Context, + listener: Response.Listener, + errorListener: Response.ErrorListener? +) : RequestBase( + context, + Method.POST, + "auth/sign-in", + Response.Listener { response -> listener.onResponse(Json.decodeFromString(response)) }, + errorListener +) { + 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/data/auth/LoginRequestData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/LoginRequestData.kt new file mode 100644 index 0000000..1ce3946 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/LoginRequestData.kt @@ -0,0 +1,6 @@ +package ru.n08i40k.polytechnic.next.network.data.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequestData(val username: String, val password: String) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/LoginResponseData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/LoginResponseData.kt new file mode 100644 index 0000000..ad59960 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/LoginResponseData.kt @@ -0,0 +1,6 @@ +package ru.n08i40k.polytechnic.next.network.data.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponseData(val id: String, val accessToken: String) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/Register.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/Register.kt new file mode 100644 index 0000000..a48aaad --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/Register.kt @@ -0,0 +1,24 @@ +package ru.n08i40k.polytechnic.next.network.data.auth + +import android.content.Context +import com.android.volley.Response +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.network.RequestBase + +class RegisterRequest( + private val data: RegisterRequestData, + context: Context, + listener: Response.Listener, + errorListener: Response.ErrorListener? +) : RequestBase( + context, + Method.POST, + "auth/sign-up", + Response.Listener { response -> listener.onResponse(Json.decodeFromString(response)) }, + errorListener +) { + 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/data/auth/RegisterRequestData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/RegisterRequestData.kt new file mode 100644 index 0000000..13954d2 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/RegisterRequestData.kt @@ -0,0 +1,12 @@ +package ru.n08i40k.polytechnic.next.network.data.auth + +import kotlinx.serialization.Serializable +import ru.n08i40k.polytechnic.next.model.UserRole + +@Serializable +data class RegisterRequestData( + val username: String, + val password: String, + val group: String, + val role: UserRole +) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/RegisterResponseData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/RegisterResponseData.kt new file mode 100644 index 0000000..d2d64b6 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/auth/RegisterResponseData.kt @@ -0,0 +1,6 @@ +package ru.n08i40k.polytechnic.next.network.data.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterResponseData(val id: String, val accessToken: String) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeGroup.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeGroup.kt new file mode 100644 index 0000000..29de4a4 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeGroup.kt @@ -0,0 +1,24 @@ +package ru.n08i40k.polytechnic.next.network.data.profile + +import android.content.Context +import com.android.volley.Response +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest + +class ChangeGroupRequest( + private val data: ChangeGroupRequestData, + context: Context, + listener: Response.Listener, + errorListener: Response.ErrorListener? +) : AuthorizedRequest( + context, + Method.POST, + "users/change-group", + Response.Listener { listener.onResponse(null) }, + errorListener +) { + 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/data/profile/ChangeGroupRequestData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeGroupRequestData.kt new file mode 100644 index 0000000..e9f7f41 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeGroupRequestData.kt @@ -0,0 +1,6 @@ +package ru.n08i40k.polytechnic.next.network.data.profile + +import kotlinx.serialization.Serializable + +@Serializable +data class ChangeGroupRequestData(val group: String) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeUsername.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeUsername.kt new file mode 100644 index 0000000..ffa3330 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeUsername.kt @@ -0,0 +1,24 @@ +package ru.n08i40k.polytechnic.next.network.data.profile + +import android.content.Context +import com.android.volley.Response +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest + +class ChangeUsernameRequest( + private val data: ChangeUsernameRequestData, + context: Context, + listener: Response.Listener, + errorListener: Response.ErrorListener? +) : AuthorizedRequest( + context, + Method.POST, + "users/change-username", + Response.Listener { listener.onResponse(null) }, + errorListener +) { + 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/data/profile/ChangeUsernameRequestData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeUsernameRequestData.kt new file mode 100644 index 0000000..3157d3f --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/ChangeUsernameRequestData.kt @@ -0,0 +1,6 @@ +package ru.n08i40k.polytechnic.next.network.data.profile + +import kotlinx.serialization.Serializable + +@Serializable +data class ChangeUsernameRequestData(val username: String) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/UsersMe.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/UsersMe.kt new file mode 100644 index 0000000..175beb2 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/profile/UsersMe.kt @@ -0,0 +1,19 @@ +package ru.n08i40k.polytechnic.next.network.data.profile + +import android.content.Context +import com.android.volley.Response +import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.model.Profile +import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest + +class UsersMeRequest( + context: Context, + listener: Response.Listener, + errorListener: Response.ErrorListener? +) : AuthorizedRequest( + context, Method.GET, "users/me", Response.Listener { response -> + listener.onResponse( + Json.decodeFromString(response) + ) + }, errorListener +) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGet.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGet.kt new file mode 100644 index 0000000..9d53b96 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGet.kt @@ -0,0 +1,22 @@ +package ru.n08i40k.polytechnic.next.network.data.schedule + +import android.content.Context +import com.android.volley.Response +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest + +class ScheduleGetRequest( + private val data: ScheduleGetRequestData, + context: Context, + listener: Response.Listener, + errorListener: Response.ErrorListener? = null +) : AuthorizedRequest( + context, Method.POST, "schedule/get-group", Response.Listener { response -> + listener.onResponse(Json.decodeFromString(response)) + }, errorListener +) { + 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/data/schedule/ScheduleGetGroupNames.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetGroupNames.kt new file mode 100644 index 0000000..9976d61 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetGroupNames.kt @@ -0,0 +1,16 @@ +package ru.n08i40k.polytechnic.next.network.data.schedule + +import android.content.Context +import com.android.volley.Response +import kotlinx.serialization.json.Json +import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest + +class ScheduleGetGroupNamesRequest( + context: Context, + listener: Response.Listener, + errorListener: Response.ErrorListener? = null +) : AuthorizedRequest( + context, Method.GET, "schedule/get-group-names", Response.Listener { response -> + listener.onResponse(Json.decodeFromString(response)) + }, errorListener +) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetGroupNamesResponseData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetGroupNamesResponseData.kt new file mode 100644 index 0000000..221d698 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetGroupNamesResponseData.kt @@ -0,0 +1,8 @@ +package ru.n08i40k.polytechnic.next.network.data.schedule + +import kotlinx.serialization.Serializable + +@Serializable +data class ScheduleGetGroupNamesResponseData( + val names: ArrayList, +) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetRequestData.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetRequestData.kt new file mode 100644 index 0000000..2d29190 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetRequestData.kt @@ -0,0 +1,6 @@ +package ru.n08i40k.polytechnic.next.network.data.schedule + +import kotlinx.serialization.Serializable + +@Serializable +data class ScheduleGetRequestData(val name: String) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetResponse.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetResponse.kt new file mode 100644 index 0000000..bf6620a --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/network/data/schedule/ScheduleGetResponse.kt @@ -0,0 +1,12 @@ +package ru.n08i40k.polytechnic.next.network.data.schedule + +import kotlinx.serialization.Serializable +import ru.n08i40k.polytechnic.next.model.Group + +@Serializable +data class ScheduleGetResponse( + val updatedAt: String, + val group: Group, + val etag: String, + val lastChangedDays: ArrayList +) \ 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 new file mode 100644 index 0000000..82a405b --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/settings/SettingsSerializer.kt @@ -0,0 +1,30 @@ +package ru.n08i40k.polytechnic.next.settings + +import android.content.Context +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import com.google.protobuf.InvalidProtocolBufferException +import ru.n08i40k.polytechnic.next.Settings +import java.io.InputStream +import java.io.OutputStream + +object SettingsSerializer : Serializer { + override val defaultValue: Settings = Settings.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): Settings { + try { + return 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( + fileName = "settings.pb", + serializer = SettingsSerializer +) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/LoadingContent.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/LoadingContent.kt new file mode 100644 index 0000000..a3e9b2a --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/LoadingContent.kt @@ -0,0 +1,50 @@ +package ru.n08i40k.polytechnic.next.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoadingContent( + empty: Boolean, + emptyContent: @Composable () -> Unit = { FullScreenLoading() }, + loading: Boolean, + onRefresh: () -> Unit, + verticalArrangement: Arrangement.Vertical = Arrangement.Center, + content: @Composable () -> Unit +) { + if (empty) emptyContent() + else { + PullToRefreshBox( + isRefreshing = loading, onRefresh = onRefresh + ) { + LazyColumn(Modifier.fillMaxSize(), verticalArrangement = verticalArrangement) { + item { + content() + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun FullScreenLoading() { + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) { + CircularProgressIndicator() + } +} \ 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 new file mode 100644 index 0000000..c82b843 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/MainActivity.kt @@ -0,0 +1,38 @@ +package ru.n08i40k.polytechnic.next.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +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 dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import ru.n08i40k.polytechnic.next.settings.settingsDataStore + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + WindowCompat.setDecorFitsSystemWindows(window, false) + super.onCreate(savedInstanceState) + + 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 new file mode 100644 index 0000000..58d3f18 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/PolytechnicApp.kt @@ -0,0 +1,33 @@ +package ru.n08i40k.polytechnic.next.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import ru.n08i40k.polytechnic.next.data.AppContainer +import ru.n08i40k.polytechnic.next.data.MockAppContainer +import ru.n08i40k.polytechnic.next.ui.auth.AuthScreen +import ru.n08i40k.polytechnic.next.ui.main.MainScreen +import ru.n08i40k.polytechnic.next.ui.theme.AppTheme + + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun PolytechnicApp() { + AppTheme(darkTheme = true, content = { + val navController = rememberNavController() + + NavHost( + navController = navController, startDestination = "auth" + ) { + composable(route = "auth") { + AuthScreen(navController) + } + + composable(route = "main") { + 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 new file mode 100644 index 0000000..81f3ab1 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/auth/AuthScreen.kt @@ -0,0 +1,525 @@ +package ru.n08i40k.polytechnic.next.ui.auth + +import android.content.Context +import androidx.compose.animation.core.animateFloatAsState +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Button +import androidx.compose.material3.Card +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.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +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 com.android.volley.AuthFailureError +import com.android.volley.ClientError +import com.android.volley.TimeoutError +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.model.AcceptableUserRoles +import ru.n08i40k.polytechnic.next.model.UserRole +import ru.n08i40k.polytechnic.next.network.data.auth.LoginRequest +import ru.n08i40k.polytechnic.next.network.data.auth.LoginRequestData +import ru.n08i40k.polytechnic.next.network.data.auth.RegisterRequest +import ru.n08i40k.polytechnic.next.network.data.auth.RegisterRequestData +import ru.n08i40k.polytechnic.next.network.data.profile.UsersMeRequest +import ru.n08i40k.polytechnic.next.settings.settingsDataStore + +@Preview(showBackground = true) +@Composable +private fun LoginForm( + mutableVisible: MutableState = mutableStateOf(true), + navController: NavHostController = rememberNavController(), + scope: CoroutineScope = rememberCoroutineScope(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + val mutableIsLoading = remember { mutableStateOf(false) } + + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var visible by mutableVisible + + Text( + text = stringResource(R.string.login_title), + modifier = Modifier.padding(10.dp), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold + ) + + Spacer(modifier = Modifier.size(10.dp)) + + val mutableUsernameError = remember { mutableStateOf(false) } + val mutablePasswordError = remember { mutableStateOf(false) } + + var usernameError by mutableUsernameError + var passwordError by mutablePasswordError + + 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 = { visible = false }) { + Text(text = stringResource(R.string.not_registered)) + } + + Button(onClick = { + if (username.length < 4) usernameError = true + if (password.isEmpty()) passwordError = true + + if (usernameError || passwordError) return@Button + + tryLogin( + username, + password, + mutableUsernameError, + mutablePasswordError, + mutableIsLoading, + context, + snackbarHostState, + scope, + navController + ) + + mutableIsLoading.value = true + focusManager.clearFocus() + }) { + Text( + text = stringResource(R.string.login), + style = MaterialTheme.typography.bodyLarge + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RegisterForm( + mutableVisible: MutableState = mutableStateOf(true), + navController: NavHostController = rememberNavController(), + scope: CoroutineScope = rememberCoroutineScope(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + val mutableIsLoading = remember { mutableStateOf(false) } + + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var group by remember { mutableStateOf("") } + val mutableRole = remember { mutableStateOf(UserRole.STUDENT) } + + var visible by mutableVisible + + Text( + text = stringResource(R.string.register_title), + modifier = Modifier.padding(10.dp), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold + ) + + Spacer(modifier = Modifier.size(10.dp)) + + val mutableUsernameError = remember { mutableStateOf(false) } + var usernameError by mutableUsernameError + + var passwordError by remember { mutableStateOf(false) } + + val mutableGroupError = remember { mutableStateOf(false) } + var groupError by mutableGroupError + + 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 + ) + + OutlinedTextField( + value = group, + singleLine = true, + onValueChange = { + groupError = false + group = it + }, + label = { Text(stringResource(R.string.group)) }, + isError = groupError + ) + + RoleSelector(mutableRole) + + TextButton(onClick = { visible = false }) { + Text(text = stringResource(R.string.already_registered)) + } + + Button( + enabled = !mutableIsLoading.value, + onClick = { + if (username.length < 4) usernameError = true + if (password.isEmpty()) passwordError = true + if (group.isEmpty()) groupError = true + + if (usernameError || passwordError || groupError) return@Button + + tryRegister( + username, + password, + group, + mutableRole.value, + mutableUsernameError, + mutableGroupError, + mutableIsLoading, + context, + snackbarHostState, + scope, + navController + ) + + mutableIsLoading.value = true + focusManager.clearFocus() + }) { + Text( + text = stringResource(R.string.register), + style = MaterialTheme.typography.bodyLarge + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AuthForm( + mutableIsLogin: MutableState = mutableStateOf(true), + navController: NavHostController = rememberNavController(), + scope: CoroutineScope = rememberCoroutineScope(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) { + var isLogin by mutableIsLogin + + val mutableVisible = remember { mutableStateOf(true) } + var visible by mutableVisible + + val animatedAlpha by animateFloatAsState( + targetValue = if (visible) 1.0f else 0f, label = "alpha" + ) + + Column( + modifier = Modifier + .padding(10.dp) + .graphicsLayer { + alpha = animatedAlpha + if (alpha == 0F) { + if (!visible) isLogin = isLogin.not() + visible = true + } + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isLogin) + LoginForm(mutableVisible, navController, scope, snackbarHostState) + else + RegisterForm(mutableVisible, navController, scope, snackbarHostState) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +fun RoleSelector(mutableRole: MutableState = mutableStateOf(UserRole.STUDENT)) { + var expanded by remember { mutableStateOf(false) } + + var role by mutableRole + + Box( + modifier = Modifier.wrapContentSize() + ) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + TextField( + label = { Text(stringResource(R.string.role)) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable), + value = stringResource(role.stringId), + leadingIcon = { + Icon( + imageVector = role.icon, + contentDescription = "role icon" + ) + }, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }) { + AcceptableUserRoles.forEach { + DropdownMenuItem( + text = { Text(stringResource(it.stringId)) }, + onClick = { + role = it + expanded = false + } + ) + } + } + } + } +} + +fun tryLogin( + // data + username: String, + password: String, + + // errors + mutableUsernameError: MutableState, + mutablePasswordError: MutableState, + + // additional + mutableIsLoading: MutableState, + context: Context, + snackbarHostState: SnackbarHostState, + scope: CoroutineScope, + navController: NavHostController +) { + var isLoading by mutableIsLoading + + LoginRequest(LoginRequestData(username, password), context, { + scope.launch { snackbarHostState.showSnackbar("Cool!") } + + runBlocking { + context.settingsDataStore.updateData { currentSettings -> + currentSettings + .toBuilder() + .setUserId(it.id) + .setAccessToken(it.accessToken) + .build() + } + } + + UsersMeRequest(context, { + runBlocking { + context.settingsDataStore.updateData { currentSettings -> + currentSettings + .toBuilder() + .setGroup(it.group) + .build() + } + } + + navController.navigate("main") + }, {}).send() + }, { + isLoading = false + + if (it is TimeoutError) { + scope.launch { snackbarHostState.showSnackbar("Request timed out!") } + } + + if (it is ClientError && it.networkResponse.statusCode == 400) scope.launch { + snackbarHostState.showSnackbar("Request schema not identical!") + } + + if (it is AuthFailureError) scope.launch { + mutableUsernameError.value = true + mutablePasswordError.value = true + snackbarHostState.showSnackbar("Invalid credentials!") + } + + + it.printStackTrace() + }).send() +} + +fun tryRegister( + // data + username: String, + password: String, + group: String, + role: UserRole, + + // errors + mutableUsernameError: MutableState, + mutableGroupError: MutableState, + + // additional + mutableIsLoading: MutableState, + context: Context, + snackbarHostState: SnackbarHostState, + scope: CoroutineScope, + navController: NavHostController +) { + var isLoading by mutableIsLoading + + RegisterRequest( + RegisterRequestData( + username, + password, + group, + role + ), context, { + scope.launch { snackbarHostState.showSnackbar("Cool!") } + + runBlocking { + context.settingsDataStore.updateData { currentSettings -> + currentSettings.toBuilder().setUserId(it.id) + .setAccessToken(it.accessToken).build() + } + } + + navController.navigate("main") + }, { + isLoading = false + + if (it is TimeoutError) { + scope.launch { snackbarHostState.showSnackbar("Request timed out!") } + } + + if (it is ClientError) scope.launch { + val statusCode = it.networkResponse.statusCode + + when (statusCode) { + 400 -> snackbarHostState.showSnackbar("Request schema not identical!") + 409 -> { + mutableUsernameError.value = true + snackbarHostState.showSnackbar("User already exists!") + } + + 404 -> { + mutableGroupError.value = true + snackbarHostState.showSnackbar("Group doesn't exists!") + } + } + } + + if (it is AuthFailureError) scope.launch { + snackbarHostState.showSnackbar( + "Invalid credentials!" + ) + } + + + it.printStackTrace() + }).send() +} + +@Preview(showBackground = true) +@Composable +fun AuthScreen(navController: NavHostController = rememberNavController()) { + val context = LocalContext.current + + LaunchedEffect(Unit) { + val accessToken: String = runBlocking { + context.settingsDataStore.data.map { settings -> settings.accessToken }.first() + } + + if (accessToken.isNotEmpty()) navController.navigate("main") + } + + val mutableIsLogin = remember { mutableStateOf(true) } + + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + 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 + ) { + Card { + AuthForm( + mutableIsLogin, + navController, + scope, + snackbarHostState + ) + } + } + }) +} \ No newline at end of file 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 new file mode 100644 index 0000000..467caf2 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/Constants.kt @@ -0,0 +1,19 @@ +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.DateRange +import androidx.compose.ui.graphics.vector.ImageVector +import ru.n08i40k.polytechnic.next.R + +data class BottomNavItem( + @StringRes val label: Int, val icon: ImageVector, val route: String +) + +object Constants { + val bottomNavItem = listOf( + BottomNavItem(R.string.profile, Icons.Filled.AccountCircle, "profile"), + BottomNavItem(R.string.schedule, Icons.Filled.DateRange, "schedule") + ) +} \ 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 new file mode 100644 index 0000000..c1db41e --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/MainScreen.kt @@ -0,0 +1,139 @@ +package ru.n08i40k.polytechnic.next.ui.main + +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.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.hilt.navigation.compose.hiltViewModel +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.settings.settingsDataStore +import ru.n08i40k.polytechnic.next.ui.main.profile.ProfileScreen +import ru.n08i40k.polytechnic.next.ui.main.schedule.ScheduleScreen +import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel +import ru.n08i40k.polytechnic.next.ui.model.ScheduleViewModel +import ru.n08i40k.polytechnic.next.ui.model.profileViewModel + + +@Composable +private fun NavHostContainer( + navController: NavHostController, + padding: PaddingValues, + scheduleViewModel: ScheduleViewModel +) { + val context = LocalContext.current + + NavHost( + navController = navController, + startDestination = Constants.bottomNavItem[1].route, + modifier = Modifier.padding(paddingValues = padding), + enterTransition = { + slideIn( + animationSpec = tween( + 500, + delayMillis = 250, + easing = LinearOutSlowInEasing + ) + ) { fullSize -> IntOffset(-fullSize.width, 0) } + }, + exitTransition = { + slideOut( + animationSpec = tween( + 500, + easing = FastOutSlowInEasing + ) + ) { fullSize -> IntOffset(fullSize.width, 0) } + }, + builder = { + composable("profile") { + ProfileScreen(LocalContext.current.profileViewModel!!) { context.profileViewModel!!.refreshProfile() } + } + + composable("schedule") { + ScheduleScreen(scheduleViewModel) { scheduleViewModel.refreshGroup() } + } + }) +} + +@Composable +private fun BottomNavBar(navController: NavHostController) { + NavigationBar { + val navBackStackEntry by navController.currentBackStackEntryAsState() + + val currentRoute = navBackStackEntry?.destination?.route + + Constants.bottomNavItem.forEach { navItem -> + NavigationBarItem( + selected = navItem.route == currentRoute, + onClick = { if (navItem.route != currentRoute) navController.navigate(navItem.route) }, + icon = { + Icon( + imageVector = navItem.icon, + contentDescription = stringResource(navItem.label) + ) + }, + label = { Text(stringResource(navItem.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") + } + + val scheduleViewModel = + hiltViewModel(LocalContext.current as ComponentActivity) + + LocalContext.current.profileViewModel = + viewModel( + factory = ProfileViewModel.provideFactory( + profileRepository = mainViewModel.appContainer.profileRepository, + onUnauthorized = { appNavController.navigate("auth") }) + ) + + val navController = rememberNavController() + Scaffold( + bottomBar = { BottomNavBar(navController = navController) } + ) { paddingValues -> + NavHostContainer( + navController, + paddingValues, + scheduleViewModel + ) + } +} \ 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/main/profile/ChangeGroupDialog.kt new file mode 100644 index 0000000..5369019 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeGroupDialog.kt @@ -0,0 +1,189 @@ +package ru.n08i40k.polytechnic.next.ui.main.profile + +import android.content.Context +import androidx.compose.foundation.layout.Box +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.layout.wrapContentSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button +import androidx.compose.material3.Card +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.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +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.network.data.profile.ChangeGroupRequest +import ru.n08i40k.polytechnic.next.network.data.profile.ChangeGroupRequestData +import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetGroupNamesRequest + +private enum class ChangeGroupError { + NOT_EXISTS +} + +private fun tryChangeGroup( + context: Context, + group: String, + onError: (ChangeGroupError) -> Unit, + onSuccess: (String) -> Unit +) { + ChangeGroupRequest(ChangeGroupRequestData(group), context, { + onSuccess(group) + }, { + if (it is ClientError && it.networkResponse.statusCode == 404) + onError(ChangeGroupError.NOT_EXISTS) + else throw it + }).send() +} + +@Composable +private fun getGroups(context: Context): ArrayList { + val groupPlaceholder = stringResource(R.string.loading); + + val groups = remember { arrayListOf(groupPlaceholder) } + + LaunchedEffect(groups) { + ScheduleGetGroupNamesRequest(context, { + groups.clear() + groups.addAll(it.names) + }, { + throw it + }).send() + } + + return groups +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +private fun GroupSelector( + value: String = "ИС-214/24", + onValueChange: (String) -> Unit = {}, + isError: Boolean = false, + readOnly: Boolean = false, +) { + var expanded by remember { mutableStateOf(false) } + + Box( + modifier = Modifier.wrapContentSize() + ) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !readOnly && !expanded + } + ) { + TextField( + label = { Text(stringResource(R.string.group)) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable), + value = value, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = "group" + ) + }, + onValueChange = {}, + isError = isError, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } + ) + + val context = LocalContext.current + val groups = getGroups(context) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }) { + groups.forEach { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + onValueChange(it) + expanded = false + } + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +internal fun ChangeGroupDialog( + context: Context = LocalContext.current, + profile: Profile = FakeProfileRepository.exampleProfile, + onChange: (String) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + Dialog(onDismissRequest = onDismiss) { + Card { + var group by remember { mutableStateOf("ИС-214/23") } + var groupError by remember { mutableStateOf(false) } + + var processing by remember { mutableStateOf(false) } + + Column(modifier = Modifier.width(IntrinsicSize.Max)) { + val modifier = Modifier.fillMaxWidth() + + GroupSelector( + value = group, + onValueChange = { group = it }, + isError = groupError, + readOnly = processing + ) + + val focusManager = LocalFocusManager.current + Button( + modifier = modifier, + onClick = { + processing = true + focusManager.clearFocus() + + tryChangeGroup( + context = context, + group = group, + onError = { + when (it) { + ChangeGroupError.NOT_EXISTS -> { + groupError = true + } + } + + processing = false + }, + onSuccess = onChange + ) + }, + enabled = !(groupError || processing) + ) { + Text(stringResource(R.string.change_group)) + } + } + } + } +} \ 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 new file mode 100644 index 0000000..5ef1652 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangePasswordDialog.kt @@ -0,0 +1,136 @@ +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.data.auth.ChangePasswordRequest +import ru.n08i40k.polytechnic.next.network.data.auth.ChangePasswordRequestData + +private enum class ChangePasswordError { + INCORRECT_CURRENT_PASSWORD, + SAME_PASSWORDS +} + +private fun tryChangePassword( + context: Context, + oldPassword: String, + newPassword: String, + onError: (ChangePasswordError) -> Unit, + onSuccess: () -> Unit +) { + ChangePasswordRequest(ChangePasswordRequestData(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/ChangeUsernameDialog.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeUsernameDialog.kt new file mode 100644 index 0000000..299fd18 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ChangeUsernameDialog.kt @@ -0,0 +1,113 @@ +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.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.network.data.profile.ChangeUsernameRequest +import ru.n08i40k.polytechnic.next.network.data.profile.ChangeUsernameRequestData + +private enum class ChangeUsernameError { + INCORRECT_LENGTH, + ALREADY_EXISTS +} + +private fun tryChangeUsername( + context: Context, + username: String, + onError: (ChangeUsernameError) -> Unit, + onSuccess: () -> Unit +) { + ChangeUsernameRequest(ChangeUsernameRequestData(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() +} + +@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 processing by remember { mutableStateOf(false) } + + Column(modifier = Modifier.width(IntrinsicSize.Max)) { + 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 + }, + label = { Text(text = stringResource(R.string.username)) }, + readOnly = processing + ) + + val focusManager = LocalFocusManager.current + Button( + modifier = modifier, + onClick = { + processing = true + focusManager.clearFocus() + + tryChangeUsername( + context = context, + username = username, + onError = { + usernameError = when (it) { + ChangeUsernameError.ALREADY_EXISTS -> true + ChangeUsernameError.INCORRECT_LENGTH -> true + } + + processing = false + }, + onSuccess = onChange + ) + }, + enabled = !(usernameError || processing) + ) { + Text(stringResource(R.string.change_username)) + } + } + } + } +} \ 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 new file mode 100644 index 0000000..9b92a0b --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileCard.kt @@ -0,0 +1,183 @@ +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.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.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.ScheduleViewModel +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() + } + }, + ) + + if (passwordChanging) { + ChangePasswordDialog( + context, + profile, + { passwordChanging = false } + ) { passwordChanging = false } + } + + if (usernameChanging) { + ChangeUsernameDialog( + context, + profile, + { + usernameChanging = false + context.profileViewModel!!.refreshProfile() + } + ) { usernameChanging = false } + } + + if (groupChanging) { + val scheduleViewModel = + hiltViewModel(LocalContext.current as ComponentActivity) + + ChangeGroupDialog( + context, + profile, + { group -> + groupChanging = false + runBlocking { + context.settingsDataStore.updateData { + it.toBuilder().setGroup(group).build() + } + } + context.profileViewModel!!.refreshProfile { + scheduleViewModel.refreshGroup() + } + } + ) { groupChanging = false } + } + } + } + } + } +} 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/main/profile/ProfileScreen.kt new file mode 100644 index 0000000..8ec80d6 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/profile/ProfileScreen.kt @@ -0,0 +1,50 @@ +package ru.n08i40k.polytechnic.next.ui.main.profile + +import androidx.compose.foundation.layout.Arrangement +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.Modifier +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 ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.data.MockAppContainer +import ru.n08i40k.polytechnic.next.ui.LoadingContent +import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState +import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel + + +@Preview(showBackground = true) +@Composable +fun ProfileScreen( + profileViewModel: ProfileViewModel = ProfileViewModel(MockAppContainer().profileRepository) {}, + onRefreshProfile: () -> Unit = {} +) { + val uiState by profileViewModel.uiState.collectAsStateWithLifecycle() + + LoadingContent( + empty = when (uiState) { + is ProfileUiState.NoProfile -> uiState.isLoading + is ProfileUiState.HasProfile -> false + }, + loading = uiState.isLoading, + onRefresh = onRefreshProfile, + verticalArrangement = Arrangement.Top + ) { + when (uiState) { + is ProfileUiState.HasProfile -> { + ProfileCard((uiState as ProfileUiState.HasProfile).profile) + } + + is ProfileUiState.NoProfile -> { + TextButton(onClick = onRefreshProfile, modifier = Modifier.fillMaxSize()) { + Text(stringResource(R.string.reload), textAlign = TextAlign.Center) + } + } + } + } +} 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/main/schedule/DayCard.kt new file mode 100644 index 0000000..eee3eca --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayCard.kt @@ -0,0 +1,179 @@ +package ru.n08i40k.polytechnic.next.ui.main.schedule + +import android.os.Handler +import android.os.Looper +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 +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.Lesson +import ru.n08i40k.polytechnic.next.model.LessonType +import java.util.Calendar + +private fun getCurrentMinutes(): Int { + return Calendar.getInstance() + .get(Calendar.HOUR_OF_DAY) * 60 + Calendar.getInstance() + .get(Calendar.MINUTE) +} + +@Composable +private fun getMinutes(): Int { + var value by remember { mutableIntStateOf(getCurrentMinutes()) } + + DisposableEffect(Unit) { + val handler = Handler(Looper.getMainLooper()) + + val runnable = { + value = getCurrentMinutes() + } + + handler.postDelayed(runnable, 60_000) + + onDispose { + handler.removeCallbacks(runnable) + } + } + + return value +} + +@Preview(showBackground = true) +@Composable +fun DayCard( + modifier: Modifier = Modifier, + day: Day? = FakeScheduleRepository.exampleGroup.days[0], + current: Boolean = true +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = if (current) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.surfaceContainerLowest) + ) { + if (day == null) { + Text( + modifier = Modifier.fillMaxWidth(), + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + text = stringResource(R.string.day_null) + ) + return@Card + } + + Text( + modifier = Modifier.fillMaxWidth(), + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + text = day.name, + ) + + val currentMinutes = getMinutes() + + val isCurrentLesson: (lesson: Lesson) -> Boolean = { + current + && it.time != null + && currentMinutes >= it.time.start + && currentMinutes <= it.time.end + } + + Column( + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(0.5.dp) + ) { + if (day.nonNullIndices.isEmpty()) { + Text("Can't get schedule!") + } else { + val defaultCardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + val customCardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + val noneCardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + + ) + + for (i in day.nonNullIndices.first()..day.nonNullIndices.last()) { + val lesson = day.lessons[i]!! + + val cardColors = when (lesson.type) { + LessonType.DEFAULT -> defaultCardColors + LessonType.CUSTOM -> customCardColors + } + + val mutableExpanded = remember { mutableStateOf(false) } + + val lessonBoxModifier = remember { + Modifier + .padding(PaddingValues(2.5.dp, 0.dp)) + .clickable { mutableExpanded.value = true } + .background(cardColors.containerColor) + } + + Box( + modifier = if (isCurrentLesson(lesson)) lessonBoxModifier.border( + border = BorderStroke( + 3.5.dp, + Color( + cardColors.containerColor.red * 0.5F, + cardColors.containerColor.green * 0.5F, + cardColors.containerColor.blue * 0.5F, + 1F + ) + ) + ) else lessonBoxModifier + ) { + LessonRow( + day, lesson, cardColors + ) + } + if (i != day.nonNullIndices.last()) { + Box( + modifier = Modifier + .padding(PaddingValues(2.5.dp, 0.dp)) + .background(noneCardColors.containerColor) + ) { + FreeLessonRow( + lesson, + day.lessons[day.nonNullIndices[day.nonNullIndices.indexOf(i) + 1]]!!, + noneCardColors + ) + } + } + + if (mutableExpanded.value) LessonExtraInfo( + lesson, mutableExpanded + ) + } + } + } + } +} \ 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/main/schedule/DayPager.kt new file mode 100644 index 0000000..2a61ea9 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/DayPager.kt @@ -0,0 +1,52 @@ +package ru.n08i40k.polytechnic.next.ui.main.schedule + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository +import ru.n08i40k.polytechnic.next.model.Group +import java.util.Calendar +import kotlin.math.absoluteValue + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun DayPager(group: Group = FakeScheduleRepository.exampleGroup) { + val currentDay = (Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 2) + val calendarDay = currentDay + .coerceAtLeast(0) + .coerceAtMost(group.days.size - 1) + + val pagerState = rememberPagerState(initialPage = calendarDay, pageCount = { group.days.size }) + + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { page -> + DayCard( + modifier = Modifier.graphicsLayer { + val offset = pagerState.getOffsetDistanceInPages(page).absoluteValue + + lerp( + start = 0.95f, stop = 1f, fraction = 1f - offset.coerceIn(0f, 1f) + ).also { scale -> + scaleX = scale + scaleY = scale + } + alpha = lerp( + start = 0.5f, stop = 1f, fraction = 1f - offset.coerceIn(0f, 1f) + ) + }, + day = group.days[page], + current = currentDay == page + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..40123ed --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/LessonView.kt @@ -0,0 +1,219 @@ +package ru.n08i40k.polytechnic.next.ui.main.schedule + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.TextAlign +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.Day +import ru.n08i40k.polytechnic.next.model.Lesson +import ru.n08i40k.polytechnic.next.model.LessonTime + +@Composable +fun LessonExtraInfo( + lesson: Lesson = FakeScheduleRepository.exampleGroup.days[0]!!.lessons[0]!!, + mutableExpanded: MutableState = mutableStateOf(true) +) { + Dialog(onDismissRequest = { mutableExpanded.value = false }) { + Column(modifier = Modifier.padding(5.dp)) { + if (lesson.teacherNames.isNotEmpty()) { + val teachers = buildString { + append(stringResource(if (lesson.teacherNames.count() > 1) R.string.lesson_teachers else R.string.lesson_teacher)) + append(" - ") + append(lesson.teacherNames.joinToString(", ")) + } + Text(teachers) + } + + val duration = buildString { + append(stringResource(R.string.lesson_duration)) + append(" - ") + val duration = if (lesson.time != null) lesson.time.end - lesson.time.start else 0 + + val hours = duration / 60 + val minutes = duration % 60 + + append(hours) + append(stringResource(R.string.hours)) + append(" ") + append(minutes) + append(stringResource(R.string.minutes)) + } + + Text(duration) + } + } +} + +private enum class LessonTimeFormat { + FROM_TO, ONLY_MINUTES_DURATION +} + +private fun numWithZero(num: Int): String { + return "0".repeat(if (num <= 9) 1 else 0) + num.toString() +} + +@Preview(showBackground = true) +@Composable +private fun LessonViewRow( + idx: Int = 1, + time: LessonTime? = LessonTime(0, 60), + timeFormat: LessonTimeFormat = LessonTimeFormat.FROM_TO, + name: String = "Test", + teacherNames: String? = "Хомченко Н.Е.", + cabinets: ArrayList = arrayListOf("14", "31"), + cardColors: CardColors = CardDefaults.cardColors(), + verticalPadding: Dp = 10.dp +) { + val contentColor = + if (timeFormat == LessonTimeFormat.FROM_TO) cardColors.contentColor else cardColors.disabledContentColor + + Row( + modifier = Modifier + .padding(10.dp, verticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (idx == -1) "1" else idx.toString(), + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = if (idx == -1) Color(0) else contentColor + ) + + Spacer(Modifier.width(7.5.dp)) + + if (time != null) { + val formattedTime: ArrayList = when (timeFormat) { + LessonTimeFormat.FROM_TO -> { + val startHour = numWithZero(time.start / 60) + val startMinute = numWithZero(time.start % 60) + + val endHour = numWithZero(time.end / 60) + val endMinute = numWithZero(time.end % 60) + + arrayListOf("$startHour:$startMinute", "$endHour:$endMinute") + } + + LessonTimeFormat.ONLY_MINUTES_DURATION -> { + val duration = time.end - time.start + + arrayListOf("$duration " + stringResource(R.string.minutes)) + } + } + + Column( + modifier = Modifier.fillMaxWidth(0.25f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = formattedTime[0], + fontFamily = FontFamily.Monospace, + color = contentColor + ) + if (formattedTime.count() > 1) { + Text( + text = formattedTime[1], + fontFamily = FontFamily.Monospace, + color = contentColor + ) + } + } + } + + Spacer(Modifier.width(7.5.dp)) + + Column( + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.fillMaxWidth(0.85f), + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = contentColor + ) + if (!teacherNames.isNullOrEmpty()) { + Text( + modifier = Modifier.fillMaxWidth(0.85f), + text = teacherNames, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = contentColor + ) + } + } + + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, + text = cabinets.joinToString(", "), + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + maxLines = 1, + color = contentColor + ) + } +} + +@Preview(showBackground = true) +@Composable +fun FreeLessonRow( + lesson: Lesson = FakeScheduleRepository.exampleGroup.days[0]!!.lessons[0]!!, + nextLesson: Lesson = FakeScheduleRepository.exampleGroup.days[0]!!.lessons[1]!!, + cardColors: CardColors = CardDefaults.cardColors() +) { + LessonViewRow( + -1, + if (lesson.time != null && nextLesson.time != null) LessonTime( + lesson.time.end, nextLesson.time.start + ) else null, + LessonTimeFormat.ONLY_MINUTES_DURATION, + stringResource(R.string.lesson_break), + null, + arrayListOf(), + cardColors, + 2.5.dp + ) +} + +@Preview(showBackground = true) +@Composable +fun LessonRow( + day: Day = FakeScheduleRepository.exampleGroup.days[0]!!, + lesson: Lesson = FakeScheduleRepository.exampleGroup.days[0]!!.lessons[0]!!, + cardColors: CardColors = CardDefaults.cardColors() +) { + LessonViewRow( + lesson.defaultIndex, + lesson.time, + LessonTimeFormat.FROM_TO, + lesson.name, + lesson.teacherNames.joinToString(", "), + lesson.cabinets, + cardColors, + 5.dp + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/ScheduleScreen.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/ScheduleScreen.kt new file mode 100644 index 0000000..6ff49e1 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/main/schedule/ScheduleScreen.kt @@ -0,0 +1,47 @@ +package ru.n08i40k.polytechnic.next.ui.main.schedule + +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.Modifier +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 ru.n08i40k.polytechnic.next.R +import ru.n08i40k.polytechnic.next.data.MockAppContainer +import ru.n08i40k.polytechnic.next.ui.LoadingContent +import ru.n08i40k.polytechnic.next.ui.model.ScheduleUiState +import ru.n08i40k.polytechnic.next.ui.model.ScheduleViewModel + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun ScheduleScreen( + scheduleViewModel: ScheduleViewModel = ScheduleViewModel(MockAppContainer()), + onRefreshSchedule: () -> Unit = {} +) { + val uiState by scheduleViewModel.uiState.collectAsStateWithLifecycle() + + LoadingContent( + empty = when (uiState) { + is ScheduleUiState.NoSchedule -> uiState.isLoading + is ScheduleUiState.HasSchedule -> false + }, + loading = uiState.isLoading, + onRefresh = onRefreshSchedule + ) { + when (uiState) { + is ScheduleUiState.HasSchedule -> { + DayPager((uiState as ScheduleUiState.HasSchedule).group) + } + + is ScheduleUiState.NoSchedule -> { + TextButton(onClick = onRefreshSchedule, 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/model/ProfileViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ProfileViewModel.kt new file mode 100644 index 0000000..1533a41 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ProfileViewModel.kt @@ -0,0 +1,92 @@ +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 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.model.Profile + +sealed interface ProfileUiState { + val isLoading: Boolean + + data class NoProfile( + override val isLoading: Boolean + ) : ProfileUiState + + data class HasProfile( + val profile: Profile, + override val isLoading: Boolean + ) : ProfileUiState +} + +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) + } +} + + +class ProfileViewModel( + private val profileRepository: ProfileRepository, + val onUnauthorized: () -> Unit +) : ViewModel() { + private val viewModelState = MutableStateFlow(ProfileViewModelState(isLoading = true)) + + val uiState = viewModelState + .map(ProfileViewModelState::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) + + init { + refreshProfile() + } + + fun refreshProfile(callback: () -> Unit = {}) { + viewModelState.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) + } + } + + callback() + } + } + + 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 + } + } + } +} + +var Context.profileViewModel: ProfileViewModel? by mutableStateOf(null) \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ScheduleViewModel.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ScheduleViewModel.kt new file mode 100644 index 0000000..07fdc5e --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/model/ScheduleViewModel.kt @@ -0,0 +1,70 @@ +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.Group +import javax.inject.Inject + +sealed interface ScheduleUiState { + val isLoading: Boolean + + data class NoSchedule( + override val isLoading: Boolean + ) : ScheduleUiState + + data class HasSchedule( + val group: Group, + override val isLoading: Boolean + ) : ScheduleUiState +} + +private data class ScheduleViewModelState( + val group: Group? = null, + val isLoading: Boolean = false +) { + fun toUiState(): ScheduleUiState = if (group == null) { + ScheduleUiState.NoSchedule(isLoading) + } else { + ScheduleUiState.HasSchedule(group, isLoading) + } +} + +@HiltViewModel +class ScheduleViewModel @Inject constructor( + appContainer: AppContainer +) : ViewModel() { + private val scheduleRepository = appContainer.scheduleRepository + private val viewModelState = MutableStateFlow(ScheduleViewModelState(isLoading = true)) + + val uiState = viewModelState + .map(ScheduleViewModelState::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) + + init { + refreshGroup() + } + + fun refreshGroup() { + viewModelState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + val result = scheduleRepository.getGroup() + + viewModelState.update { + when (result) { + is MyResult.Success -> it.copy(group = result.data, isLoading = false) + is MyResult.Failure -> it.copy(group = null, isLoading = false) + } + } + } + } +} diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Color.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Color.kt new file mode 100644 index 0000000..94ca7e3 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Color.kt @@ -0,0 +1,226 @@ +package ru.n08i40k.polytechnic.next.ui.theme + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF4C662B) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFCDEDA3) +val onPrimaryContainerLight = Color(0xFF102000) +val secondaryLight = Color(0xFF586249) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFDCE7C8) +val onSecondaryContainerLight = Color(0xFF151E0B) +val tertiaryLight = Color(0xFF386663) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFBCECE7) +val onTertiaryContainerLight = Color(0xFF00201E) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF410002) +val backgroundLight = Color(0xFFF9FAEF) +val onBackgroundLight = Color(0xFF1A1C16) +val surfaceLight = Color(0xFFF9FAEF) +val onSurfaceLight = Color(0xFF1A1C16) +val surfaceVariantLight = Color(0xFFE1E4D5) +val onSurfaceVariantLight = Color(0xFF44483D) +val outlineLight = Color(0xFF75796C) +val outlineVariantLight = Color(0xFFC5C8BA) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF2F312A) +val inverseOnSurfaceLight = Color(0xFFF1F2E6) +val inversePrimaryLight = Color(0xFFB1D18A) +val surfaceDimLight = Color(0xFFDADBD0) +val surfaceBrightLight = Color(0xFFF9FAEF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF3F4E9) +val surfaceContainerLight = Color(0xFFEEEFE3) +val surfaceContainerHighLight = Color(0xFFE8E9DE) +val surfaceContainerHighestLight = Color(0xFFE2E3D8) + +val primaryLightMediumContrast = Color(0xFF314A12) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF617D3F) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF3C462F) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF6E785E) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF1A4A47) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF4F7D79) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF8C0009) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFDA342E) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFF9FAEF) +val onBackgroundLightMediumContrast = Color(0xFF1A1C16) +val surfaceLightMediumContrast = Color(0xFFF9FAEF) +val onSurfaceLightMediumContrast = Color(0xFF1A1C16) +val surfaceVariantLightMediumContrast = Color(0xFFE1E4D5) +val onSurfaceVariantLightMediumContrast = Color(0xFF404439) +val outlineLightMediumContrast = Color(0xFF5D6155) +val outlineVariantLightMediumContrast = Color(0xFF787C70) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF2F312A) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF1F2E6) +val inversePrimaryLightMediumContrast = Color(0xFFB1D18A) +val surfaceDimLightMediumContrast = Color(0xFFDADBD0) +val surfaceBrightLightMediumContrast = Color(0xFFF9FAEF) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF3F4E9) +val surfaceContainerLightMediumContrast = Color(0xFFEEEFE3) +val surfaceContainerHighLightMediumContrast = Color(0xFFE8E9DE) +val surfaceContainerHighestLightMediumContrast = Color(0xFFE2E3D8) + +val primaryLightHighContrast = Color(0xFF142700) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF314A12) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF1C2511) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF3C462F) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF002725) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF1A4A47) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF4E0002) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF8C0009) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFF9FAEF) +val onBackgroundLightHighContrast = Color(0xFF1A1C16) +val surfaceLightHighContrast = Color(0xFFF9FAEF) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE1E4D5) +val onSurfaceVariantLightHighContrast = Color(0xFF21251C) +val outlineLightHighContrast = Color(0xFF404439) +val outlineVariantLightHighContrast = Color(0xFF404439) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF2F312A) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFD6F7AC) +val surfaceDimLightHighContrast = Color(0xFFDADBD0) +val surfaceBrightLightHighContrast = Color(0xFFF9FAEF) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF3F4E9) +val surfaceContainerLightHighContrast = Color(0xFFEEEFE3) +val surfaceContainerHighLightHighContrast = Color(0xFFE8E9DE) +val surfaceContainerHighestLightHighContrast = Color(0xFFE2E3D8) + +val primaryDark = Color(0xFFB1D18A) +val onPrimaryDark = Color(0xFF1F3701) +val primaryContainerDark = Color(0xFF354E16) +val onPrimaryContainerDark = Color(0xFFCDEDA3) +val secondaryDark = Color(0xFFBFCBAD) +val onSecondaryDark = Color(0xFF2A331E) +val secondaryContainerDark = Color(0xFF404A33) +val onSecondaryContainerDark = Color(0xFFDCE7C8) +val tertiaryDark = Color(0xFFA0D0CB) +val onTertiaryDark = Color(0xFF003735) +val tertiaryContainerDark = Color(0xFF1F4E4B) +val onTertiaryContainerDark = Color(0xFFBCECE7) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF12140E) +val onBackgroundDark = Color(0xFFE2E3D8) +val surfaceDark = Color(0xFF12140E) +val onSurfaceDark = Color(0xFFE2E3D8) +val surfaceVariantDark = Color(0xFF44483D) +val onSurfaceVariantDark = Color(0xFFC5C8BA) +val outlineDark = Color(0xFF8F9285) +val outlineVariantDark = Color(0xFF44483D) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE2E3D8) +val inverseOnSurfaceDark = Color(0xFF2F312A) +val inversePrimaryDark = Color(0xFF4C662B) +val surfaceDimDark = Color(0xFF12140E) +val surfaceBrightDark = Color(0xFF383A32) +val surfaceContainerLowestDark = Color(0xFF0C0F09) +val surfaceContainerLowDark = Color(0xFF1A1C16) +val surfaceContainerDark = Color(0xFF1E201A) +val surfaceContainerHighDark = Color(0xFF282B24) +val surfaceContainerHighestDark = Color(0xFF33362E) + +val primaryDarkMediumContrast = Color(0xFFB5D58E) +val onPrimaryDarkMediumContrast = Color(0xFF0C1A00) +val primaryContainerDarkMediumContrast = Color(0xFF7D9A59) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFC4CFB1) +val onSecondaryDarkMediumContrast = Color(0xFF101907) +val secondaryContainerDarkMediumContrast = Color(0xFF8A9579) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFA4D4D0) +val onTertiaryDarkMediumContrast = Color(0xFF001A19) +val tertiaryContainerDarkMediumContrast = Color(0xFF6B9995) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFBAB1) +val onErrorDarkMediumContrast = Color(0xFF370001) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF12140E) +val onBackgroundDarkMediumContrast = Color(0xFFE2E3D8) +val surfaceDarkMediumContrast = Color(0xFF12140E) +val onSurfaceDarkMediumContrast = Color(0xFFFBFCF0) +val surfaceVariantDarkMediumContrast = Color(0xFF44483D) +val onSurfaceVariantDarkMediumContrast = Color(0xFFC9CCBE) +val outlineDarkMediumContrast = Color(0xFFA1A497) +val outlineVariantDarkMediumContrast = Color(0xFF818578) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE2E3D8) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF282B24) +val inversePrimaryDarkMediumContrast = Color(0xFF364F17) +val surfaceDimDarkMediumContrast = Color(0xFF12140E) +val surfaceBrightDarkMediumContrast = Color(0xFF383A32) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF0C0F09) +val surfaceContainerLowDarkMediumContrast = Color(0xFF1A1C16) +val surfaceContainerDarkMediumContrast = Color(0xFF1E201A) +val surfaceContainerHighDarkMediumContrast = Color(0xFF282B24) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF33362E) + +val primaryDarkHighContrast = Color(0xFFF4FFDF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFB5D58E) +val onPrimaryContainerDarkHighContrast = Color(0xFF000000) +val secondaryDarkHighContrast = Color(0xFFF4FFDF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFC4CFB1) +val onSecondaryContainerDarkHighContrast = Color(0xFF000000) +val tertiaryDarkHighContrast = Color(0xFFEAFFFC) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFA4D4D0) +val onTertiaryContainerDarkHighContrast = Color(0xFF000000) +val errorDarkHighContrast = Color(0xFFFFF9F9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFBAB1) +val onErrorContainerDarkHighContrast = Color(0xFF000000) +val backgroundDarkHighContrast = Color(0xFF12140E) +val onBackgroundDarkHighContrast = Color(0xFFE2E3D8) +val surfaceDarkHighContrast = Color(0xFF12140E) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF44483D) +val onSurfaceVariantDarkHighContrast = Color(0xFFF9FCED) +val outlineDarkHighContrast = Color(0xFFC9CCBE) +val outlineVariantDarkHighContrast = Color(0xFFC9CCBE) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE2E3D8) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF1A3000) +val surfaceDimDarkHighContrast = Color(0xFF12140E) +val surfaceBrightDarkHighContrast = Color(0xFF383A32) +val surfaceContainerLowestDarkHighContrast = Color(0xFF0C0F09) +val surfaceContainerLowDarkHighContrast = Color(0xFF1A1C16) +val surfaceContainerDarkHighContrast = Color(0xFF1E201A) +val surfaceContainerHighDarkHighContrast = Color(0xFF282B24) +val surfaceContainerHighestDarkHighContrast = Color(0xFF33362E) + + + + + + + diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Theme.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Theme.kt new file mode 100644 index 0000000..77b2e3d --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Theme.kt @@ -0,0 +1,278 @@ +package ru.n08i40k.polytechnic.next.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color +) + +val unspecified_scheme = ColorFamily( + Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified +) + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable() () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkScheme + else -> lightScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content + ) +} + diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Type.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Type.kt new file mode 100644 index 0000000..1ec53e1 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package ru.n08i40k.polytechnic.next.ui.theme + +import androidx.compose.material3.Typography + +val AppTypography = Typography() diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/EnumAsIntSerializer.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/EnumAsIntSerializer.kt new file mode 100644 index 0000000..1cd1b99 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/EnumAsIntSerializer.kt @@ -0,0 +1,27 @@ +package ru.n08i40k.polytechnic.next.utils + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +open class EnumAsIntSerializer>( + serialName: String, + val serialize: (v: T) -> Int, + val deserialize: (v: Int) -> T +) : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(serialName, PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: T) { + encoder.encodeInt(serialize(value)) + } + + override fun deserialize(decoder: Decoder): T { + val v = decoder.decodeInt() + return deserialize(v) + } +} + diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/EnumAsStringSerializer.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/EnumAsStringSerializer.kt new file mode 100644 index 0000000..4b1b6dc --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/EnumAsStringSerializer.kt @@ -0,0 +1,26 @@ +package ru.n08i40k.polytechnic.next.utils + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +open class EnumAsStringSerializer>( + serialName: String, + val serialize: (v: T) -> String, + val deserialize: (v: String) -> T +) : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(serialName, PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: T) { + encoder.encodeString(serialize(value)) + } + + override fun deserialize(decoder: Decoder): T { + val v = decoder.decodeString() + return deserialize(v) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/n08i40k/polytechnic/next/utils/ErrorMessage.kt b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/ErrorMessage.kt new file mode 100644 index 0000000..2c84cd5 --- /dev/null +++ b/app/src/main/java/ru/n08i40k/polytechnic/next/utils/ErrorMessage.kt @@ -0,0 +1,3 @@ +package ru.n08i40k.polytechnic.next.utils + +data class ErrorMessage(val message: String) \ No newline at end of file diff --git a/app/src/main/proto/settings.proto b/app/src/main/proto/settings.proto new file mode 100644 index 0000000..6e5b8fa --- /dev/null +++ b/app/src/main/proto/settings.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +option java_package = "ru.n08i40k.polytechnic.next"; +option java_multiple_files = true; + +message Settings { + string user_id = 1; + string access_token = 2; + string group = 3; +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..d7dbdae --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_round.xml b/app/src/main/res/drawable/ic_launcher_round.xml new file mode 100644 index 0000000..d7dbdae --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_round.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/logo.xml b/app/src/main/res/drawable/logo.xml new file mode 100644 index 0000000..321679e --- /dev/null +++ b/app/src/main/res/drawable/logo.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/raw/ssl.pem b/app/src/main/res/raw/ssl.pem new file mode 100644 index 0000000..dd5a581 --- /dev/null +++ b/app/src/main/res/raw/ssl.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEpjCCAw6gAwIBAgIRAO5cnXTnxJXAIMgjHjt8VNAwDQYJKoZIhvcNAQELBQAw +azEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSAwHgYDVQQLDBdIT01F +LVBDXG4wOGk0MGtASE9NRS1QQzEnMCUGA1UEAwwebWtjZXJ0IEhPTUUtUENcbjA4 +aTQwa0BIT01FLVBDMB4XDTI0MDQyMTIyMjE1MVoXDTM0MDQyMTIyMjE1MVowazEe +MBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSAwHgYDVQQLDBdIT01FLVBD +XG4wOGk0MGtASE9NRS1QQzEnMCUGA1UEAwwebWtjZXJ0IEhPTUUtUENcbjA4aTQw +a0BIT01FLVBDMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAxdT1huby +eoG+X/kJoh1tn6jVJB/zN1r0/O7PZSZXJ9qYHufl6IwEfPlKJPNfpYifiSZ1x/R+ +flzmfk8Q3L/njIh3WOURmKhPuHyUnruVQnM/66FqRAp0gSntuGnQ8y/JEkSRtzRI +p75Rp/1NktDRqi8UOq1+aXCKwyh/jcu2gaJ9L0EKRqpxrGjm2hF3yYRQU/DRcWhc +VJKpMW4TpelRxUTM5QSdqsoe+GoKgLQeHzLXRSmRdTMFql/yG3Cy3MfYNR/oA+Nu +Nlt1ozPBTrtQ0LhTlToJkALB2cBxodRGJs871eDqzTzDHbJ6+OjlNy4xtZEgDTqt +XIl0wexOTjVk+31ClLsaqtfWDsyFPpAG4G7+eAUNPXY1MSiui3SmF5G3tLKUMmci +A6hV7MSBOhSF+Rl0pzZ6t8/aANmZerpALymRRzpp93z3evdQqRtki1oi4x1SIb5h +cNHgvwTzb6vSxd17lTiB78bWGVsDO3wy7JMuWRdA/AClxufeA8d7RlkPAgMBAAGj +RTBDMA4GA1UdDwEB/wQEAwICBDASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQW +BBRR8ZgtYNodDB7WWBFlx8lNwWlkZDANBgkqhkiG9w0BAQsFAAOCAYEAtMxjGIF0 +7I3UzM/2e/VWV8zaKRP4y7Nc3SbmygPi1kE+MJUtGyGqnOtVvTwVqmN1YMYe7Z3f +/IsjuMJfmDGq71gNf/frGDykOojpGnzk7R0d4VDWGA3cP+urSumilcz5a/nCfrps +zU5ZIM53t30Eg1FUdpJ84a9n2MF5rTBoFwez1JoeUUYdSI2SpbOHCAIzH3VLfQX0 +n/80aWxrykpPsvqVE171fpq7JHYXgftCR/aEOrMNpq0mlv3uQ8nJ0ejLcHG5FOCn +0sjx3ol/5VbVQItfhUHzQab0ubNaxl3VhxJEuqlvqJn0A0VoisCARPp1k9IIwz78 +LuuPUMjzmazBWgtE4Sj95M159f+A7tHeTXRDElSVb1QOAiHiFwBR6Eiw4jfjzJMi +RbZTh4aZ7YNtMFLROv3B8CP9TfcdyX/SeKRFwBp9mCotg9pxNuDwrwQ36+zyxv4N +W98lNodK590KS7F7P1P1oFS+cguzxiGJ3du4O5TTOY7VdgS+gz8DlBE+ +-----END CERTIFICATE----- diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..7927d37 --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=ru-RU \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..c0a1767 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,33 @@ + + + Политехникум + Имя пользователя + Пароль + Зарегистрироваться + Авторизоваться + Авторизация + Регистрация + Не зарегистрированы? + Уже зарегистрированы? + Перезагрузить + Преподаватель + Преподаватели + Длительность + ч. + мин. + Перемена + Расписание + Профиль + Студент + Преподаватель + Администратор + Группа + Роль + Расписание ещё не обновилось. + Старый пароль + Новый пароль + Загрузка… + Сменить пароль + Сменить имя пользователя + Сменить группу + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +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 new file mode 100644 index 0000000..45308c9 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,33 @@ + + Polytechnic + + Username + Password + Register + Login + Login + Registration + Not registered? + Already registered? + Reload + Teacher + Teachers + Duration + h. + min. + Break + Schedule + Profile + Student + Teacher + Administrator + Group + Role + Schedule not updated yet. + Old password + New password + Change password + Change username + Loading… + Change group + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a58e19b --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +