3.0.0 / 3.0.1

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,19 @@
package ru.n08i40k.polytechnic.next.network
import android.content.Context
import com.android.volley.VolleyError
import com.android.volley.toolbox.RequestFuture
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.utils.MyResult
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeoutException
fun <ResultT, RequestT : RequestBase> tryFuture(
context: Context,
buildRequest: (RequestFuture<ResultT>) -> RequestT
): MyResult<ResultT> {
val future = RequestFuture.newFuture<ResultT>()
buildRequest(future).send()
buildRequest(future).send(context)
return tryGet(future)
}
@@ -31,6 +33,6 @@ fun <T> tryGet(future: RequestFuture<T>): MyResult<T> {
fun unwrapException(exception: Exception): Throwable {
if (exception is ExecutionException && exception.cause != null)
return exception.cause!!
return exception
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
package ru.n08i40k.polytechnic.next.ui.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@Composable
fun NavHostContainer(
navHostController: NavHostController,
padding: PaddingValues,
startDestination: String,
routes: Map<String, @Composable () -> Unit>,
enterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = null,
exitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = null,
sizeTransform: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> SizeTransform?)? = null,
) {
NavHost(
navController = navHostController,
modifier = Modifier.padding(padding),
startDestination = startDestination,
enterTransition = enterTransition ?: {
slideIn(
animationSpec = tween(
400,
delayMillis = 250,
easing = LinearOutSlowInEasing
)
) { fullSize -> IntOffset(0, fullSize.height / 16) } + fadeIn(
animationSpec = tween(
400,
delayMillis = 250,
easing = LinearOutSlowInEasing
)
)
},
exitTransition = exitTransition ?: {
fadeOut(
animationSpec = tween(
250,
easing = FastOutSlowInEasing
)
)
},
sizeTransform = sizeTransform
) {
routes.forEach { route -> composable(route.key) { route.value() } }
}
}

View File

@@ -0,0 +1,57 @@
package ru.n08i40k.polytechnic.next.ui.navigation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import ru.n08i40k.polytechnic.next.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopAppBar(badge: Boolean, items: List<@Composable ColumnScope.() -> Unit>) {
var dropdownExpanded by remember { mutableStateOf(false) }
TopAppBar(
title = {
Text(
stringResource(R.string.app_name),
Modifier.fillMaxWidth(),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
},
actions = {
IconButton({ dropdownExpanded = true }) {
BadgedBox({ if (badge) Badge() }) {
Icon(Icons.Filled.Menu, stringResource(R.string.cd_top_app_bar))
}
}
DropdownMenu(dropdownExpanded, { dropdownExpanded = false }) {
Column(Modifier.wrapContentWidth()) {
items.forEach { it() }
}
}
}
)
}

View File

@@ -0,0 +1,267 @@
package ru.n08i40k.polytechnic.next.ui.screen
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.Application
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.ui.AppRoute
import ru.n08i40k.polytechnic.next.ui.icons.AppIcons
import ru.n08i40k.polytechnic.next.ui.icons.appicons.Filled
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Download
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Telegram
import ru.n08i40k.polytechnic.next.ui.model.GroupViewModel
import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState
import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel
import ru.n08i40k.polytechnic.next.ui.model.RemoteConfigUiState
import ru.n08i40k.polytechnic.next.ui.model.RemoteConfigViewModel
import ru.n08i40k.polytechnic.next.ui.model.SearchViewModel
import ru.n08i40k.polytechnic.next.ui.model.TeacherViewModel
import ru.n08i40k.polytechnic.next.ui.navigation.BottomNavBar
import ru.n08i40k.polytechnic.next.ui.navigation.BottomNavItem
import ru.n08i40k.polytechnic.next.ui.navigation.NavHostContainer
import ru.n08i40k.polytechnic.next.ui.navigation.TopAppBar
import ru.n08i40k.polytechnic.next.ui.screen.profile.ProfileScreen
import ru.n08i40k.polytechnic.next.ui.screen.replacer.ReplacerScreen
import ru.n08i40k.polytechnic.next.ui.screen.schedule.GroupScheduleScreen
import ru.n08i40k.polytechnic.next.ui.screen.schedule.TeacherScheduleScreen
import ru.n08i40k.polytechnic.next.ui.screen.schedule.TeacherSearchScreen
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
import ru.n08i40k.polytechnic.next.utils.openLink
private data class MainBottomNavItem(
val bottomNavItem: BottomNavItem,
val requiredRole: UserRole?
)
private enum class MainScreenRoute(val route: String) {
PROFILE("profile"),
REPLACER("replacer"),
TEACHER_SCHEDULE("teacher-schedule"),
GROUP_SCHEDULE("group-schedule"),
TEACHER_SEARCH("teacher-search")
}
private val mainNavBarItems = listOf(
MainBottomNavItem(
BottomNavItem(
R.string.profile,
Icons.Filled.AccountCircle,
MainScreenRoute.PROFILE.route
),
null
),
MainBottomNavItem(
BottomNavItem(
R.string.replacer,
Icons.Filled.Create,
MainScreenRoute.REPLACER.route
),
UserRole.ADMIN
),
MainBottomNavItem(
BottomNavItem(
R.string.teacher_schedule,
Icons.Filled.Person,
MainScreenRoute.TEACHER_SCHEDULE.route
),
UserRole.TEACHER
),
MainBottomNavItem(
BottomNavItem(
R.string.group_schedule,
Icons.Filled.DateRange,
MainScreenRoute.GROUP_SCHEDULE.route
),
null
),
MainBottomNavItem(
BottomNavItem(
R.string.teachers_schedule,
Icons.Filled.Person,
MainScreenRoute.TEACHER_SEARCH.route
),
UserRole.STUDENT
),
)
@Composable
private fun LinkButton(
text: String,
icon: ImageVector,
link: String,
enabled: Boolean = true,
badged: Boolean = false,
) {
val context = LocalContext.current
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = { context.openLink(link) },
enabled = enabled,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
BadgedBox(badge = { if (badged) Badge() }) {
Icon(
imageVector = icon,
contentDescription = text
)
}
Spacer(Modifier.width(5.dp))
Text(text)
}
}
}
private fun topBarItems(
context: Context,
remoteConfigUiState: RemoteConfigUiState
): Pair<Boolean, List<@Composable ColumnScope.() -> Unit>> {
val packageVersion = (context.applicationContext as Application).version
val updateAvailable = remoteConfigUiState.currVersion != packageVersion
return Pair<Boolean, List<@Composable ColumnScope.() -> Unit>>(
updateAvailable,
listOf(
{
LinkButton(
text = stringResource(R.string.download_update),
icon = AppIcons.Filled.Download,
link = remoteConfigUiState.downloadLink,
enabled = updateAvailable,
badged = updateAvailable
)
},
{
LinkButton(
text = stringResource(R.string.telegram_channel),
icon = AppIcons.Filled.Telegram,
link = remoteConfigUiState.telegramLink,
)
}
)
)
}
@Composable
fun MainScreen(navController: NavController) {
val context = LocalContext.current
LaunchedEffect(context) {
runBlocking {
val accessToken = context.settings.data.map { it.accessToken }.first()
if (accessToken.isEmpty()) {
navController.navigate(AppRoute.AUTH.route) {
popUpTo(AppRoute.AUTH.route) { inclusive = true }
}
}
}
}
val viewModelStoreOwner = LocalActivity.current as ComponentActivity
val profileViewModel = hiltViewModel<ProfileViewModel>(viewModelStoreOwner)
val profileUiState by profileViewModel.uiState.collectAsStateWithLifecycle()
LoadingContent(
empty = false,
emptyContent = {},
loading = profileUiState.isLoading,
) {
val profile =
if (profileUiState is ProfileUiState.HasData)
(profileUiState as ProfileUiState.HasData).profile
else
null
val role = profile?.role ?: UserRole.STUDENT
val items =
mainNavBarItems.filter {
it.requiredRole == null
|| (role == UserRole.ADMIN
|| it.requiredRole == role)
}
val groupViewModel = hiltViewModel<GroupViewModel>(viewModelStoreOwner)
val remoteConfigViewModel = hiltViewModel<RemoteConfigViewModel>(viewModelStoreOwner)
val remoteConfigUiState by remoteConfigViewModel.uiState.collectAsStateWithLifecycle()
val teacherViewModel =
if (role === UserRole.STUDENT)
null
else
hiltViewModel<TeacherViewModel>(viewModelStoreOwner)
val searchViewModel =
if (role === UserRole.TEACHER)
null
else
hiltViewModel<SearchViewModel>(viewModelStoreOwner)
val routes = mapOf<String, @Composable () -> Unit>(
MainScreenRoute.PROFILE.route to { ProfileScreen(profileViewModel) },
MainScreenRoute.REPLACER.route to { ReplacerScreen() },
MainScreenRoute.TEACHER_SCHEDULE.route to { TeacherScheduleScreen(teacherViewModel!!) },
MainScreenRoute.GROUP_SCHEDULE.route to { GroupScheduleScreen(groupViewModel) },
MainScreenRoute.TEACHER_SEARCH.route to { TeacherSearchScreen(searchViewModel!!) },
)
val topAppBar = topBarItems(context, remoteConfigUiState)
val navHostController = rememberNavController()
Scaffold(
topBar = { TopAppBar(topAppBar.first, topAppBar.second) },
bottomBar = { BottomNavBar(navHostController, items.map { it.bottomNavItem }) }
) { paddingValues ->
NavHostContainer(
navHostController,
paddingValues,
if (role == UserRole.TEACHER)
MainScreenRoute.TEACHER_SCHEDULE.route
else
MainScreenRoute.GROUP_SCHEDULE.route,
routes
)
}
}
}

View File

@@ -0,0 +1,161 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.android.gms.tasks.OnCompleteListener
import com.google.android.gms.tasks.Task
import com.google.firebase.messaging.FirebaseMessaging
import ru.n08i40k.polytechnic.next.ui.AppRoute
import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar
import ru.n08i40k.polytechnic.next.ui.helper.SnackbarBox
import ru.n08i40k.polytechnic.next.worker.UpdateFCMTokenWorker
enum class AuthRoute(val route: String) {
SignUp("sign-up"),
SignIn("sign-in"),
}
@Composable
private fun FormWrapper(
onWidthChange: (Dp) -> Unit,
content: @Composable BoxScope.() -> Unit
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Card(border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)) {
val localDensity = LocalDensity.current
Box(
Modifier
.padding(10.dp)
.onGloballyPositioned {
with(localDensity) {
onWidthChange(it.size.width.toDp())
}
}
/*.animateContentSize()*/,
content = content
)
}
}
}
@Composable
private fun AuthForm(parentNavController: NavController, pushSnackbar: PushSnackbar) {
val navController = rememberNavController()
val context = LocalContext.current
val switch: () -> Unit = {
navController.navigate(
if (navController.currentDestination?.route == AuthRoute.SignUp.route)
AuthRoute.SignIn.route
else
AuthRoute.SignUp.route
)
}
val finish: () -> Unit = {
parentNavController.navigate(AppRoute.MAIN.route) {
popUpTo(AppRoute.AUTH.route) { inclusive = true }
}
FirebaseMessaging.getInstance().token.addOnCompleteListener(object :
OnCompleteListener<String> {
override fun onComplete(token: Task<String?>) {
if (!token.isSuccessful)
return
UpdateFCMTokenWorker.schedule(context, token.result!!)
}
})
}
NavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
startDestination = "sign-up",
enterTransition = {
fadeIn(
animationSpec = tween(
durationMillis = 700,
delayMillis = 800,
easing = LinearOutSlowInEasing
)
) + scaleIn(
animationSpec = tween(
durationMillis = 400,
delayMillis = 700,
easing = LinearOutSlowInEasing
)
)
},
exitTransition = {
slideOut(
animationSpec = tween(
durationMillis = 250,
easing = LinearEasing
)
) { fullSize -> IntOffset(0, fullSize.height / 16) } + fadeOut(
animationSpec = tween(
durationMillis = 250,
easing = LinearEasing
)
)
},
) {
composable(AuthRoute.SignUp.route) {
var width by remember { mutableStateOf(Dp.Unspecified) }
FormWrapper({ width = it }) {
SignUpForm(pushSnackbar, switch, finish, width)
}
}
composable(AuthRoute.SignIn.route) {
var width by remember { mutableStateOf(Dp.Unspecified) }
FormWrapper({ width = it }) {
SignInCard(pushSnackbar, switch, finish, width)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthScreen(navController: NavController) {
SnackbarBox {
Box(contentAlignment = Alignment.Center) {
AuthForm(navController, it)
}
}
}

View File

@@ -0,0 +1,164 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.rememberNavController
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar
import ru.n08i40k.polytechnic.next.ui.navigation.NavHostContainer
import ru.n08i40k.polytechnic.next.ui.screen.auth.signin.ManualPage
import ru.n08i40k.polytechnic.next.ui.screen.auth.signin.VKOneTap
private enum class SignInPage(val route: String) {
SELECT("select"),
MANUAL("manual"),
}
@Preview(showBackground = true)
@Composable
private fun SelectSignInMethod(
onSelected: (SignInPage) -> Unit = {},
switch: () -> Unit = {},
toApp: () -> Unit = {},
pushSnackbar: PushSnackbar = { _, _ -> },
) {
val modifier = Modifier.width(240.dp)
var vkId by remember { mutableStateOf(false) }
Column(
modifier,
verticalArrangement = Arrangement.spacedBy(5.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(R.string.sign_in_title),
Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
Box(modifier, contentAlignment = Alignment.Center) {
Button({ onSelected(SignInPage.MANUAL) }, modifier, !vkId) {
Text(stringResource(R.string.sign_in_manual), fontWeight = FontWeight.Bold)
}
Row(modifier.padding(10.dp, 0.dp)) {
Icon(
Icons.Filled.Create,
stringResource(R.string.cd_manual_icon),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
VKOneTap(toApp, pushSnackbar) { vkId = it }
Box(modifier, contentAlignment = Alignment.Center) {
HorizontalDivider()
Text(
stringResource(R.string.or_divider),
Modifier.background(CardDefaults.cardColors().containerColor)
)
}
Button(switch, modifier, !vkId) {
Text(stringResource(R.string.sign_in_not_registered))
}
}
}
@Composable
fun SignInCard(
pushSnackbar: PushSnackbar,
toSignUp: () -> Unit,
toApp: () -> Unit,
parentWidth: Dp,
) {
val navHostController = rememberNavController()
val toSelect: () -> Unit = {
navHostController.navigate(SignInPage.SELECT.route) {
popUpTo(SignInPage.SELECT.route) { inclusive = true }
}
}
NavHostContainer(
navHostController,
PaddingValues(0.dp),
SignInPage.SELECT.route,
mapOf<String, @Composable () -> Unit>(
SignInPage.SELECT.route to {
SelectSignInMethod(
{ page -> navHostController.navigate(page.route) },
toSignUp,
toApp,
pushSnackbar
)
},
SignInPage.MANUAL.route to {
ManualPage(
pushSnackbar,
toApp,
toSelect,
parentWidth
)
}
),
enterTransition = {
slideIn(
animationSpec = tween(
400,
delayMillis = 500,
easing = LinearOutSlowInEasing
)
) { fullSize -> IntOffset(0, -fullSize.height / 16) } + fadeIn(
animationSpec = tween(
400,
delayMillis = 500,
easing = LinearOutSlowInEasing
)
)
},
sizeTransform = {
SizeTransform { initialSize, targetSize ->
keyframes {
durationMillis = 250
delayMillis = 250
}
}
}
)
}

View File

@@ -0,0 +1,164 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.rememberNavController
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar
import ru.n08i40k.polytechnic.next.ui.navigation.NavHostContainer
import ru.n08i40k.polytechnic.next.ui.screen.auth.signup.ManualPage
import ru.n08i40k.polytechnic.next.ui.screen.auth.signup.VKPage
import ru.n08i40k.polytechnic.next.ui.widgets.OneTapComplete
private enum class SignUpPage(val route: String) {
SELECT("select"),
MANUAL("manual"),
VK("vk")
}
@Preview(showBackground = true)
@Composable
private fun SelectSignUpMethod(
onSelected: (SignUpPage, String?) -> Unit = { _, _ -> },
switch: () -> Unit = {}
) {
val modifier = Modifier.width(240.dp)
Column(
modifier,
verticalArrangement = Arrangement.spacedBy(5.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(R.string.sign_up_title),
Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
Box(modifier, contentAlignment = Alignment.Center) {
Button({ onSelected(SignUpPage.MANUAL, null) }, modifier) {
Text(stringResource(R.string.sign_up_manual), fontWeight = FontWeight.Bold)
}
Row(modifier.padding(10.dp, 0.dp)) {
Icon(
Icons.Filled.Create,
stringResource(R.string.cd_manual_icon),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
OneTapComplete(onAuth = { onSelected(SignUpPage.VK, it) }, onFail = {})
Box(modifier, contentAlignment = Alignment.Center) {
HorizontalDivider()
Text(
stringResource(R.string.or_divider),
Modifier.background(CardDefaults.cardColors().containerColor)
)
}
Button(switch, modifier) {
Text(stringResource(R.string.sign_up_already_registered))
}
}
}
@Preview(showBackground = true)
@Composable
fun SignUpForm(
pushSnackbar: PushSnackbar = { msg, dur -> },
toSignIn: () -> Unit = {},
toApp: () -> Unit = {},
parentWidth: Dp = Dp.Unspecified,
) {
val navHostController = rememberNavController()
val toSelect: () -> Unit = {
navHostController.navigate(SignUpPage.SELECT.route) {
popUpTo(SignUpPage.SELECT.route) { inclusive = true }
}
}
var accessToken by remember { mutableStateOf<String?>(null) }
NavHostContainer(
navHostController,
PaddingValues(0.dp),
SignUpPage.SELECT.route,
mapOf<String, @Composable () -> Unit>(
SignUpPage.SELECT.route to {
SelectSignUpMethod(
{ page, token ->
navHostController.navigate(page.route)
accessToken = token
},
toSignIn
)
},
SignUpPage.MANUAL.route to {
ManualPage(pushSnackbar, toApp, toSelect, parentWidth)
},
SignUpPage.VK.route to {
VKPage(accessToken!!, pushSnackbar, toApp, toSelect, parentWidth)
}
),
enterTransition = {
slideIn(
animationSpec = tween(
400,
delayMillis = 500,
easing = LinearOutSlowInEasing
)
) { fullSize -> IntOffset(0, -fullSize.height / 16) } + fadeIn(
animationSpec = tween(
400,
delayMillis = 500,
easing = LinearOutSlowInEasing
)
)
},
sizeTransform = {
SizeTransform { initialSize, targetSize ->
keyframes {
durationMillis = 250
delayMillis = 250
}
}
}
)
}

View File

@@ -0,0 +1,229 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth.signin
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignIn
import ru.n08i40k.polytechnic.next.network.unwrapException
import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar
import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue
import java.util.logging.Logger
private fun trySignIn(
context: Context,
username: String,
password: String,
onSuccess: () -> Unit,
onError: (SignInError) -> Unit,
) {
AuthSignIn(
AuthSignIn.RequestDto(username, password),
{
runBlocking {
context.settings.updateData { settings ->
settings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.setGroup(it.group)
.build()
}
}
onSuccess()
},
{
val error = mapError(unwrapException(it))
if (error == SignInError.UNKNOWN) {
val logger = Logger.getLogger("tryRegister")
logger.severe("Unknown exception while trying to register!")
logger.severe(it.toString())
}
onError(error)
}
).send(context)
}
@Composable
internal fun ManualPage(
pushSnackbar: PushSnackbar,
toApp: () -> Unit,
toSelect: () -> Unit,
parentWidth: Dp,
) {
val context = LocalContext.current
var username by rememberInputValue<String>("") { it.length < 4 }
var password by rememberInputValue<String>("") { it.isEmpty() }
var invalidCredentials by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val onClick: () -> Unit = fun() {
focusManager.clearFocus(true)
loading = true
trySignIn(
context,
username.value,
password.value,
{
loading = false
toApp()
},
{
loading = false
when (it) {
SignInError.INCORRECT_CREDENTIALS -> username = username.copy(isError = true)
else -> Unit
}
pushSnackbar(getErrorMessage(context, it, false), SnackbarDuration.Long)
}
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
Modifier.defaultMinSize(parentWidth, Dp.Unspecified),
contentAlignment = Alignment.Center
) {
if (parentWidth != Dp.Unspecified) {
Row(Modifier.width(parentWidth)) {
IconButton(toSelect) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
stringResource(R.string.cd_back_icon)
)
}
}
}
Text(
stringResource(R.string.sign_in_title),
Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
}
val onValueChange: () -> Unit = {
if (invalidCredentials) {
invalidCredentials = false
username = username.copy(isError = false, checkNow = true)
password = password.copy(isError = false, checkNow = true)
}
}
OutlinedTextField(
username.value,
{
onValueChange()
username = username.copy(
it.filter { it != ' ' }.lowercase(),
isError = false
)
},
readOnly = loading,
label = { Text(stringResource(R.string.username)) },
isError = username.isError,
singleLine = true,
keyboardOptions = KeyboardOptions(
KeyboardCapitalization.None,
autoCorrectEnabled = false,
KeyboardType.Ascii,
ImeAction.Next
)
)
OutlinedTextField(
password.value,
{
onValueChange()
password = password.copy(
it,
isError = false
)
},
readOnly = loading,
label = { Text(stringResource(R.string.password)) },
isError = password.isError,
singleLine = true,
keyboardOptions = KeyboardOptions(
KeyboardCapitalization.None,
autoCorrectEnabled = false,
KeyboardType.Password,
ImeAction.Next
),
visualTransformation = PasswordVisualTransformation()
)
if (parentWidth != Dp.Unspecified) {
Spacer(Modifier.height(10.dp))
val canProceed = !loading
&& (!username.isError && username.value.isNotEmpty())
&& (!password.isError && password.value.isNotEmpty())
Button(
onClick,
Modifier.width(parentWidth),
enabled = canProceed
) {
Text(
stringResource(R.string.proceed),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}

View File

@@ -0,0 +1,62 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth.signin
import android.content.Context
import com.android.volley.ClientError
import com.android.volley.NoConnectionError
import com.android.volley.TimeoutError
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignIn
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignIn.Companion.ErrorCode
import java.util.concurrent.TimeoutException
enum class SignInError {
// server errors
INCORRECT_CREDENTIALS,
INVALID_VK_ACCESS_TOKEN,
// client errors
TIMED_OUT,
NO_CONNECTION,
UNKNOWN
}
fun mapError(exception: Throwable): SignInError {
return when (exception) {
is TimeoutException -> SignInError.TIMED_OUT
is TimeoutError -> SignInError.TIMED_OUT
is NoConnectionError -> SignInError.NO_CONNECTION
is ClientError -> {
if (exception.networkResponse.statusCode != 406)
return SignInError.UNKNOWN
val error = AuthSignIn.Companion.parseError(exception)
when (error.code) {
ErrorCode.INVALID_VK_ACCESS_TOKEN -> SignInError.INVALID_VK_ACCESS_TOKEN
ErrorCode.INCORRECT_CREDENTIALS -> SignInError.INCORRECT_CREDENTIALS
}
}
else -> SignInError.UNKNOWN
}
}
fun getErrorMessage(context: Context, error: SignInError, isVK: Boolean): String {
return context.getString(
when (error) {
SignInError.UNKNOWN -> R.string.unknown_error
SignInError.INVALID_VK_ACCESS_TOKEN -> R.string.auth_error_invalid_vk_access_token
SignInError.INCORRECT_CREDENTIALS ->
if (isVK)
R.string.auth_error_vk_not_linked
else
R.string.auth_error_incorrect_credentials
SignInError.TIMED_OUT -> R.string.timed_out
SignInError.NO_CONNECTION -> R.string.no_connection
}
)
}

View File

@@ -0,0 +1,80 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth.signin
import android.content.Context
import androidx.compose.material3.SnackbarDuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignInVK
import ru.n08i40k.polytechnic.next.network.unwrapException
import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.ui.widgets.OneTapComplete
import java.util.logging.Logger
private fun trySignIn(
context: Context,
accessToken: String,
onSuccess: () -> Unit,
onError: (SignInError) -> Unit,
) {
AuthSignInVK(
AuthSignInVK.RequestDto(accessToken),
{
runBlocking {
context.settings.updateData { settings ->
settings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.setGroup(it.group)
.build()
}
}
onSuccess()
},
{
val error = mapError(unwrapException(it))
if (error == SignInError.UNKNOWN) {
val logger = Logger.getLogger("trySignIn")
logger.severe("Unknown exception while trying to sign-in!")
logger.severe(it.toString())
}
onError(error)
}
).send(context)
}
@Preview(showBackground = true)
@Composable
fun VKOneTap(
toApp: () -> Unit = {},
pushSnackbar: (String, SnackbarDuration) -> Unit = { _, _ -> },
onProcess: (Boolean) -> Unit = {}
) {
val context = LocalContext.current
OneTapComplete(
onAuth = {
onProcess(true)
trySignIn(
context,
it,
toApp
) {
pushSnackbar(getErrorMessage(context, it, true), SnackbarDuration.Long)
onProcess(false)
}
},
onFail = {}
)
}

View File

@@ -0,0 +1,276 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth.signup
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.Application
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUp
import ru.n08i40k.polytechnic.next.network.unwrapException
import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar
import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue
import ru.n08i40k.polytechnic.next.ui.widgets.selector.GroupSelector
import ru.n08i40k.polytechnic.next.ui.widgets.selector.RoleSelector
import ru.n08i40k.polytechnic.next.ui.widgets.selector.TeacherNameSelector
import java.util.logging.Logger
private fun trySignUp(
context: Context,
username: String,
password: String,
group: String,
role: UserRole,
onSuccess: () -> Unit,
onError: (SignUpError) -> Unit,
) {
AuthSignUp(
AuthSignUp.RequestDto(
username,
password,
group,
role,
(context.applicationContext as Application).version
),
{
runBlocking {
context.settings.updateData { settings ->
settings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.setGroup(group)
.build()
}
}
onSuccess()
},
{
val error = mapError(unwrapException(it))
if (error == SignUpError.UNKNOWN) {
val logger = Logger.getLogger("tryRegister")
logger.severe("Unknown exception while trying to register!")
logger.severe(it.toString())
}
onError(error)
}
).send(context)
}
@Composable
internal fun ManualPage(
pushSnackbar: PushSnackbar,
toApp: () -> Unit,
toSelect: () -> Unit,
parentWidth: Dp,
) {
val context = LocalContext.current
var username by rememberInputValue<String>("") { it.length < 4 }
var password by rememberInputValue<String>("") { it.isEmpty() }
var group by rememberInputValue<String?>(null) { it == null }
var role by remember { mutableStateOf(UserRole.STUDENT) }
var loading by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val onClick: () -> Unit = fun() {
focusManager.clearFocus(true)
loading = true
trySignUp(
context,
username.value,
password.value,
group.value!!,
role,
{
loading = false
toApp()
},
{
loading = false
when (it) {
SignUpError.USERNAME_ALREADY_EXISTS -> username = username.copy(isError = true)
SignUpError.INVALID_GROUP_NAME -> group = group.copy(isError = true)
else -> Unit
}
pushSnackbar(getErrorMessage(context, it), SnackbarDuration.Long)
}
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
Modifier.defaultMinSize(parentWidth, Dp.Unspecified),
contentAlignment = Alignment.Center
) {
if (parentWidth != Dp.Unspecified) {
Row(Modifier.width(parentWidth)) {
IconButton(toSelect) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
stringResource(R.string.cd_back_icon)
)
}
}
}
Text(
stringResource(R.string.sign_up_title),
Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
}
when (role) {
UserRole.TEACHER -> {
TeacherNameSelector(
username.value, {
username = username.copy(
value = it,
isError = false
)
},
isError = username.isError,
readOnly = loading
)
}
UserRole.STUDENT -> {
OutlinedTextField(
username.value,
{
username = username.copy(
value = it.filter { it != ' ' }.lowercase(),
isError = false
)
},
readOnly = loading,
label = { Text(stringResource(R.string.username)) },
isError = username.isError,
singleLine = true,
keyboardOptions = KeyboardOptions(
KeyboardCapitalization.None,
autoCorrectEnabled = false,
KeyboardType.Ascii,
ImeAction.Next
)
)
}
else -> Unit
}
OutlinedTextField(
password.value,
{
password = password.copy(
value = it,
isError = false
)
},
readOnly = loading,
label = { Text(stringResource(R.string.password)) },
isError = password.isError,
singleLine = true,
keyboardOptions = KeyboardOptions(
KeyboardCapitalization.None,
autoCorrectEnabled = false,
KeyboardType.Password,
ImeAction.Next
),
visualTransformation = PasswordVisualTransformation(),
)
Spacer(Modifier.height(10.dp))
GroupSelector(
group.value,
{
group = group.copy(
value = it,
isError = false
)
},
isError = group.isError,
readOnly = loading,
supervised = role == UserRole.TEACHER
)
Spacer(Modifier.height(10.dp))
RoleSelector(
role,
false,
loading
) { role = it }
if (parentWidth != Dp.Unspecified) {
Spacer(Modifier.height(10.dp))
val canProceed = !loading
&& (!username.isError && username.value.isNotEmpty())
&& (!password.isError && password.value.isNotEmpty())
&& (!group.isError && group.value != null)
Button(
onClick,
Modifier.width(parentWidth),
enabled = canProceed
) {
Text(
stringResource(R.string.proceed),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}

View File

@@ -0,0 +1,66 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth.signup
import android.content.Context
import com.android.volley.ClientError
import com.android.volley.NoConnectionError
import com.android.volley.TimeoutError
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUp
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUp.Companion.ErrorCode
import java.util.concurrent.TimeoutException
enum class SignUpError {
// server errors
USERNAME_ALREADY_EXISTS,
VK_ALREADY_EXISTS,
INVALID_VK_ACCESS_TOKEN,
INVALID_GROUP_NAME,
DISALLOWED_ROLE,
// client errors
TIMED_OUT,
NO_CONNECTION,
UNKNOWN
}
fun mapError(exception: Throwable): SignUpError {
return when (exception) {
is TimeoutException -> SignUpError.TIMED_OUT
is TimeoutError -> SignUpError.TIMED_OUT
is NoConnectionError -> SignUpError.NO_CONNECTION
is ClientError -> {
if (exception.networkResponse.statusCode != 406)
return SignUpError.UNKNOWN
val error = AuthSignUp.Companion.parseError(exception)
when (error.code) {
ErrorCode.USERNAME_ALREADY_EXISTS -> SignUpError.USERNAME_ALREADY_EXISTS
ErrorCode.VK_ALREADY_EXISTS -> SignUpError.VK_ALREADY_EXISTS
ErrorCode.INVALID_VK_ACCESS_TOKEN -> SignUpError.INVALID_VK_ACCESS_TOKEN
ErrorCode.INVALID_GROUP_NAME -> SignUpError.INVALID_GROUP_NAME
ErrorCode.DISALLOWED_ROLE -> SignUpError.DISALLOWED_ROLE
}
}
else -> SignUpError.UNKNOWN
}
}
fun getErrorMessage(context: Context, error: SignUpError): String {
return context.getString(
when (error) {
SignUpError.UNKNOWN -> R.string.unknown_error
SignUpError.USERNAME_ALREADY_EXISTS -> R.string.auth_error_username_already_exists
SignUpError.VK_ALREADY_EXISTS -> R.string.auth_error_vk_already_exists
SignUpError.INVALID_VK_ACCESS_TOKEN -> R.string.auth_error_invalid_vk_access_token
SignUpError.INVALID_GROUP_NAME -> R.string.auth_error_invalid_group_name
SignUpError.DISALLOWED_ROLE -> R.string.auth_error_disallowed_role
SignUpError.TIMED_OUT -> R.string.timed_out
SignUpError.NO_CONNECTION -> R.string.no_connection
}
)
}

View File

@@ -0,0 +1,253 @@
package ru.n08i40k.polytechnic.next.ui.screen.auth.signup
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.Application
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUpVK
import ru.n08i40k.polytechnic.next.network.unwrapException
import ru.n08i40k.polytechnic.next.settings.settings
import ru.n08i40k.polytechnic.next.ui.helper.PushSnackbar
import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue
import ru.n08i40k.polytechnic.next.ui.widgets.selector.GroupSelector
import ru.n08i40k.polytechnic.next.ui.widgets.selector.RoleSelector
import ru.n08i40k.polytechnic.next.ui.widgets.selector.TeacherNameSelector
import java.util.logging.Logger
private fun trySignUp(
context: Context,
accessToken: String,
username: String,
group: String,
role: UserRole,
onSuccess: () -> Unit,
onError: (SignUpError) -> Unit,
) {
AuthSignUpVK(
AuthSignUpVK.RequestDto(
accessToken,
username,
group,
role,
(context.applicationContext as Application).version
),
{
runBlocking {
context.settings.updateData { settings ->
settings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.setGroup(group)
.build()
}
}
onSuccess()
},
{
val error = mapError(unwrapException(it))
if (error == SignUpError.UNKNOWN) {
val logger = Logger.getLogger("trySignUp")
logger.severe("Unknown exception while trying to sign up!")
logger.severe(it.toString())
}
onError(error)
}
).send(context)
}
@Composable
internal fun VKPage(
accessToken: String,
pushSnackbar: PushSnackbar,
toApp: () -> Unit,
toSelect: () -> Unit,
parentWidth: Dp,
) {
val context = LocalContext.current
var username by rememberInputValue<String>("") { it.length < 4 }
var group by rememberInputValue<String?>(null) { it == null }
var role by remember { mutableStateOf(UserRole.STUDENT) }
var loading by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val onClick: () -> Unit = fun() {
focusManager.clearFocus(true)
loading = true
trySignUp(
context,
accessToken,
username.value,
group.value!!,
role,
{
loading = false
toApp()
},
{
loading = false
when (it) {
SignUpError.USERNAME_ALREADY_EXISTS -> username = username.copy(isError = true)
SignUpError.INVALID_GROUP_NAME -> group = group.copy(isError = true)
else -> Unit
}
pushSnackbar(getErrorMessage(context, it), SnackbarDuration.Long)
}
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
Modifier.defaultMinSize(parentWidth, Dp.Unspecified),
contentAlignment = Alignment.Center
) {
if (parentWidth != Dp.Unspecified) {
Row(Modifier.width(parentWidth)) {
IconButton(toSelect) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
stringResource(R.string.cd_back_icon)
)
}
}
}
Text(
stringResource(R.string.sign_up_title),
Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
}
when (role) {
UserRole.TEACHER -> {
TeacherNameSelector(
username.value,
{
username = username.copy(
value = it,
isError = false
)
},
isError = username.isError,
readOnly = loading
)
}
UserRole.STUDENT -> {
OutlinedTextField(
username.value,
{
username = username.copy(
value = it.filter { it != ' ' }.lowercase(),
isError = false
)
},
readOnly = loading,
label = { Text(stringResource(R.string.username)) },
isError = username.isError,
singleLine = true,
keyboardOptions = KeyboardOptions(
KeyboardCapitalization.None,
autoCorrectEnabled = false,
KeyboardType.Ascii,
ImeAction.Next
)
)
}
else -> Unit
}
Spacer(Modifier.height(10.dp))
GroupSelector(
group.value, {
group = group.copy(
value = it,
isError = false
)
},
isError = group.isError,
readOnly = loading,
supervised = role == UserRole.TEACHER
)
Spacer(Modifier.height(10.dp))
RoleSelector(
role,
false,
loading
) { role = it }
if (parentWidth != Dp.Unspecified) {
Spacer(Modifier.height(10.dp))
val canProceed = !loading
&& (!username.isError && username.value.isNotEmpty())
&& (!group.isError && group.value != null)
Button(
onClick,
Modifier.width(parentWidth),
enabled = canProceed
) {
Text(
stringResource(R.string.proceed),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}

View File

@@ -1,4 +1,4 @@
package ru.n08i40k.polytechnic.next.ui.main.profile
package ru.n08i40k.polytechnic.next.ui.screen.profile
import android.content.Context
import androidx.compose.foundation.layout.Column
@@ -21,11 +21,13 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.Dialog
import com.android.volley.ClientError
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
import ru.n08i40k.polytechnic.next.app.appContainer
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.network.request.profile.ProfileChangeGroup
import ru.n08i40k.polytechnic.next.ui.widgets.GroupSelector
import ru.n08i40k.polytechnic.next.repository.profile.impl.MockProfileRepository
import ru.n08i40k.polytechnic.next.ui.helper.data.rememberInputValue
import ru.n08i40k.polytechnic.next.ui.widgets.selector.GroupSelector
private enum class ChangeGroupError {
NOT_EXISTS
@@ -35,29 +37,31 @@ private fun tryChangeGroup(
context: Context,
group: String,
onError: (ChangeGroupError) -> Unit,
onSuccess: (String) -> Unit
onSuccess: () -> Unit
) {
ProfileChangeGroup(ProfileChangeGroup.RequestDto(group), context, {
onSuccess(group)
}, {
if (it is ClientError && it.networkResponse.statusCode == 404)
onError(ChangeGroupError.NOT_EXISTS)
else throw it
}).send()
ProfileChangeGroup(
context.appContainer,
ProfileChangeGroup.RequestDto(group),
{ onSuccess() },
{
if (it is ClientError && it.networkResponse.statusCode == 404)
onError(ChangeGroupError.NOT_EXISTS)
else throw it
}
).send(context)
}
@Preview(showBackground = true)
@Composable
internal fun ChangeGroupDialog(
context: Context = LocalContext.current,
profile: Profile = FakeProfileRepository.exampleProfile,
onChange: (String) -> Unit = {},
profile: Profile = MockProfileRepository.profile,
onChange: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
Dialog(onDismissRequest = onDismiss) {
Card {
var group by remember { mutableStateOf<String?>(profile.group) }
var groupError by remember { mutableStateOf(false) }
var group by rememberInputValue<String?>(profile.group) { it == null }
var processing by remember { mutableStateOf(false) }
@@ -65,35 +69,32 @@ internal fun ChangeGroupDialog(
val modifier = Modifier.fillMaxWidth()
GroupSelector(
value = group,
isError = groupError,
group.value,
{ group = group.copy(value = it, isError = false) },
isError = group.isError,
readOnly = processing,
teacher = profile.role == UserRole.TEACHER
) { group = it }
supervised = profile.role == UserRole.TEACHER
)
val focusManager = LocalFocusManager.current
Button(
modifier = modifier,
onClick = {
{
processing = true
focusManager.clearFocus()
tryChangeGroup(
context = context,
group = group!!,
group = group.value!!,
onError = {
when (it) {
ChangeGroupError.NOT_EXISTS -> {
groupError = true
}
}
group = group.copy(isError = true)
processing = false
},
onSuccess = onChange
)
},
enabled = !(groupError || processing) && group != null
modifier,
!(group.isError || processing) && group.value != null
) {
Text(stringResource(R.string.change_group))
}

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