mirror of
https://github.com/n08i40k/polytechnic-android.git
synced 2025-12-06 17:57:46 +03:00
3.0.0 / 3.0.1
This commit is contained in:
@@ -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 |
89
app/src/main/java/ru/n08i40k/polytechnic/next/Application.kt
Normal file
89
app/src/main/java/ru/n08i40k/polytechnic/next/Application.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
116
app/src/main/java/ru/n08i40k/polytechnic/next/MainActivity.kt
Normal file
116
app/src/main/java/ru/n08i40k/polytechnic/next/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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)) },
|
||||
|
||||
@@ -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)) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
@@ -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 = "")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() } }
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user